diff --git a/frontend/src/pages/Deploy/NotificationChannel/List/components/hooks/useNotificationChannelForm.ts b/frontend/src/pages/Deploy/NotificationChannel/List/components/hooks/useNotificationChannelForm.ts index ffa367f5..1ccbe399 100644 --- a/frontend/src/pages/Deploy/NotificationChannel/List/components/hooks/useNotificationChannelForm.ts +++ b/frontend/src/pages/Deploy/NotificationChannel/List/components/hooks/useNotificationChannelForm.ts @@ -5,7 +5,7 @@ import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useToast } from '@/components/ui/use-toast'; -import { NotificationChannelType, NotificationChannelStatus } from '../../types'; +import { NotificationChannelType } from '../../types'; import type { NotificationChannel } from '../../types'; import type { ConfigState } from '../config-strategies/types'; import { ConfigStrategyFactory } from '../config-strategies/ConfigStrategyFactory'; @@ -33,7 +33,7 @@ export const useNotificationChannelForm = ({ const [selectedType, setSelectedType] = useState( NotificationChannelType.WEWORK ); - const [currentStatus, setCurrentStatus] = useState(); + const [currentEnabled, setCurrentEnabled] = useState(true); const [configState, setConfigState] = useState({}); // 表单管理 @@ -85,7 +85,7 @@ export const useNotificationChannelForm = ({ setSelectedType(defaultType); setConfigState(strategy.loadFromData({ channelType: defaultType, config: {} })); - setCurrentStatus(undefined); + setCurrentEnabled(true); }; // 加载编辑数据 @@ -104,7 +104,7 @@ export const useNotificationChannelForm = ({ }); setSelectedType(data.channelType); - setCurrentStatus(data.status); + setCurrentEnabled(data.enabled); // 使用策略加载配置状态 const strategy = ConfigStrategyFactory.getStrategy(data.channelType); @@ -135,14 +135,8 @@ export const useNotificationChannelForm = ({ channelType: values.channelType, config, description: values.description, + enabled: mode === 'create' ? true : currentEnabled, }; - - // 设置状态 - if (mode === 'create') { - payload.status = NotificationChannelStatus.ENABLED; - } else if (mode === 'edit' && currentStatus) { - payload.status = currentStatus; - } if (mode === 'edit' && editId) { await updateChannel(editId, payload); diff --git a/frontend/src/pages/Deploy/NotificationChannel/List/index.tsx b/frontend/src/pages/Deploy/NotificationChannel/List/index.tsx index 32e49905..cfd2fecb 100644 --- a/frontend/src/pages/Deploy/NotificationChannel/List/index.tsx +++ b/frontend/src/pages/Deploy/NotificationChannel/List/index.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { PageContainer } from '@/components/ui/page-container'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -16,6 +17,13 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useToast } from '@/components/ui/use-toast'; @@ -31,13 +39,24 @@ import { RefreshCw, Power, PowerOff, + Activity, + Database, + Server, + MoreHorizontal, } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import type { NotificationChannel, NotificationChannelQuery, ChannelTypeOption, } from './types'; -import { NotificationChannelStatus, NotificationChannelType } from './types'; +import { NotificationChannelType } from './types'; import { getChannelsPage, getChannelTypes, @@ -68,7 +87,7 @@ const NotificationChannelList: React.FC = () => { const [query, setQuery] = useState({ name: '', channelType: undefined, - status: undefined, + enabled: undefined, }); // 对话框状态 @@ -87,7 +106,7 @@ const NotificationChannelList: React.FC = () => { // 加载数据 useEffect(() => { loadData(); - }, [pagination.pageNum, pagination.pageSize, query.channelType, query.status]); + }, [pagination.pageNum, pagination.pageSize, query.channelType, query.enabled]); const loadChannelTypes = async () => { try { @@ -138,7 +157,7 @@ const NotificationChannelList: React.FC = () => { setQuery({ name: '', channelType: undefined, - status: undefined, + enabled: undefined, }); setPagination({ pageNum: DEFAULT_CURRENT, @@ -235,215 +254,230 @@ const NotificationChannelList: React.FC = () => { }; // 状态标签 - const getStatusBadge = (status?: NotificationChannelStatus) => { - if (status === NotificationChannelStatus.ENABLED) { + const getStatusBadge = (enabled: boolean) => { + if (enabled) { return 启用; } return 禁用; }; return ( -
- {/* 页面标题 */} -
-

消息中心

- + + {/* 统计卡片 */} +
+ + + 总渠道数 + + + +
{channels.length}
+

所有渠道

+
+
+ + + 已启用 + + + +
+ {channels.filter(c => c.enabled).length} +
+

正在使用中

+
+
+ + + 已禁用 + + + +
+ {channels.filter(c => !c.enabled).length} +
+

暂时停用

+
+
- {/* 搜索筛选 */} -
-
- -
- - setQuery({ ...query, name: e.target.value })} - onKeyPress={(e) => { - if (e.key === 'Enter') { - handleSearch(); - } - }} - className="pl-10" + {/* 渠道管理 */} + + +
+
+ 通知渠道 + + 管理通知渠道配置,支持企业微信、邮件等多种渠道 + +
+ +
+
+ + {/* 搜索区域 */} +
+
+ setQuery({ ...query, name: e.target.value })} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSearch(); + } + }} + /> +
+ + +
+ + {/* 表格 */} +
+ + + + 渠道名称 + 渠道类型 + 状态 + 描述 + 创建时间 + 操作 + + + + {loading ? ( + + + + + + ) : channels.length === 0 ? ( + + + 暂无数据 + + + ) : ( + channels.map((channel) => ( + + {channel.name} + + {getChannelTypeBadge(channel.channelType)} + + + + {channel.enabled ? "启用" : "禁用"} + + + + {channel.description || '-'} + + {channel.createTime} + +
+ + {channel.enabled ? ( + + ) : ( + + )} + + +
+
+
+ )) + )} +
+
+
+ + {/* 分页 */} +
+
-
- -
- - -
- -
- - -
- - - - -
- - {/* 表格 */} -
- - - - ID - 渠道名称 - 渠道类型 - 状态 - 描述 - 创建时间 - 操作 - - - - {loading ? ( - - - - - - ) : channels.length === 0 ? ( - - - 暂无数据 - - - ) : ( - channels.map((channel) => ( - - {channel.id} - {channel.name} - {getChannelTypeBadge(channel.channelType)} - {getStatusBadge(channel.status)} - - {channel.description || '-'} - - {channel.createTime} - -
- - {channel.status === NotificationChannelStatus.ENABLED ? ( - - ) : ( - - )} - - -
-
-
- )) - )} -
-
-
- - {/* 分页 */} - {!loading && channels.length > 0 && ( -
- -
- )} + + {/* 创建/编辑对话框 */} { variant="destructive" confirmText="删除" /> -
+
); }; diff --git a/frontend/src/pages/Deploy/NotificationChannel/List/types.ts b/frontend/src/pages/Deploy/NotificationChannel/List/types.ts index 8917a630..b1d0d684 100644 --- a/frontend/src/pages/Deploy/NotificationChannel/List/types.ts +++ b/frontend/src/pages/Deploy/NotificationChannel/List/types.ts @@ -6,13 +6,7 @@ export enum NotificationChannelType { EMAIL = 'EMAIL' // 邮件 } -/** - * 通知渠道状态枚举 - */ -export enum NotificationChannelStatus { - ENABLED = 'ENABLED', // 启用 - DISABLED = 'DISABLED' // 禁用 -} +// 通知渠道状态现在使用布尔值 enabled 字段 // ============================================ // 2. 配置基类(不直接使用,用于类型推断) @@ -83,8 +77,8 @@ export interface NotificationChannelDTO { */ config: WeworkNotificationConfigDTO | EmailNotificationConfigDTO; - /** 状态 */ - status?: NotificationChannelStatus; + /** 是否启用 */ + enabled: boolean; /** 描述 */ description?: string; @@ -150,7 +144,7 @@ export interface ChannelTypeOption { export interface NotificationChannelQuery { name?: string; // 渠道名称(模糊查询) channelType?: NotificationChannelType; // 渠道类型 - status?: NotificationChannelStatus; // 状态 + enabled?: boolean; // 是否启用 // 分页参数 page?: number; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/components/NotificationTemplateDialog.tsx b/frontend/src/pages/Deploy/NotificationTemplate/List/components/NotificationTemplateDialog.tsx new file mode 100644 index 00000000..b9564b86 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/components/NotificationTemplateDialog.tsx @@ -0,0 +1,410 @@ +/** + * 通知模板创建/编辑对话框 + */ +import React, { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/components/ui/use-toast'; +import { Loader2, Eye, Code } from 'lucide-react'; +import { NotificationChannelType } from '../../../NotificationChannel/List/types'; +import type { + NotificationTemplateDTO, + ChannelTypeOption, + TemplateVariable +} from '../types'; +import { + createTemplate, + updateTemplate, + getTemplateById, + checkTemplateCodeUnique, +} from '../service'; +import { TemplateEditor, TemplatePreview } from './'; + +interface NotificationTemplateDialogProps { + open: boolean; + editId?: number; + channelTypes: ChannelTypeOption[]; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +// 表单验证Schema +const formSchema = z.object({ + name: z.string().min(1, '请输入模板名称').max(100, '模板名称不能超过100个字符'), + code: z.string() + .min(1, '请输入模板编码') + .max(50, '模板编码不能超过50个字符') + .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, '模板编码只能包含字母、数字和下划线,且以字母开头'), + description: z.string().max(500, '描述不能超过500个字符').optional(), + channelType: z.nativeEnum(NotificationChannelType), + contentTemplate: z.string().min(1, '请输入模板内容'), +}); + +type FormValues = z.infer; + +export const NotificationTemplateDialog: React.FC = ({ + open, + editId, + channelTypes, + onOpenChange, + onSuccess, +}) => { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [variables, setVariables] = useState([]); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + reset, + setValue, + watch, + setError, + clearErrors, + } = useForm({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + channelType: NotificationChannelType.WEWORK, + contentTemplate: '', + }, + }); + + const mode = editId ? 'edit' : 'create'; + const watchedChannelType = watch('channelType'); + const watchedCode = watch('code'); + const watchedTemplate = watch('contentTemplate'); + + // 获取模板变量(硬编码常用变量) + const getVariables = (channelType: NotificationChannelType): TemplateVariable[] => { + const commonVars: TemplateVariable[] = [ + { name: 'projectName', description: '项目名称', example: 'deploy-ease-platform' }, + { name: 'buildNumber', description: '构建号', example: '123' }, + { name: 'buildStatus', description: '构建状态', example: 'SUCCESS' }, + { name: 'operator', description: '操作人', example: 'admin' }, + { name: 'timestamp', description: '时间戳', example: new Date().toLocaleString() }, + ]; + + if (channelType === NotificationChannelType.EMAIL) { + return [ + ...commonVars, + { name: 'recipient', description: '收件人', example: 'user@example.com' }, + { name: 'subject', description: '邮件主题', example: '部署通知' }, + ]; + } + + return commonVars; + }; + + // 监听渠道类型变化 + useEffect(() => { + if (watchedChannelType) { + setVariables(getVariables(watchedChannelType)); + } + }, [watchedChannelType]); + + // 验证编码唯一性 + const validateCodeUnique = async (code: string) => { + if (!code || code.length < 1) return; + + try { + const isUnique = await checkTemplateCodeUnique(code, editId); + if (!isUnique) { + setError('code', { message: '模板编码已存在' }); + } else { + clearErrors('code'); + } + } catch (error) { + console.error('Failed to check code uniqueness:', error); + } + }; + + // 监听编码变化进行唯一性验证 + useEffect(() => { + if (watchedCode && mode === 'create') { + const timer = setTimeout(() => { + validateCodeUnique(watchedCode); + }, 500); + return () => clearTimeout(timer); + } + }, [watchedCode, mode]); + + // 加载编辑数据 + useEffect(() => { + if (open && editId) { + loadTemplateData(); + } else if (open) { + // 新建时重置 + reset({ + name: '', + code: '', + description: '', + channelType: NotificationChannelType.WEWORK, + contentTemplate: '', + }); + setShowPreview(false); + } + }, [open, editId]); + + const loadTemplateData = async () => { + if (!editId) return; + + setLoading(true); + try { + const data = await getTemplateById(editId); + reset({ + name: data.name, + code: data.code, + description: data.description || '', + channelType: data.channelType, + contentTemplate: data.contentTemplate, + }); + } catch (error: any) { + toast({ + variant: 'destructive', + title: '加载失败', + description: error.response?.data?.message || error.message, + }); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + const onSubmit = async (values: FormValues) => { + setSaving(true); + try { + const payload = { + name: values.name, + code: values.code, + description: values.description, + channelType: values.channelType, + contentTemplate: values.contentTemplate, + }; + + if (mode === 'edit' && editId) { + await updateTemplate(editId, payload); + toast({ title: '更新成功' }); + } else { + await createTemplate(payload); + toast({ title: '创建成功' }); + } + + onSuccess(); + onOpenChange(false); + } catch (error: any) { + toast({ + variant: 'destructive', + title: mode === 'edit' ? '更新失败' : '创建失败', + description: error.response?.data?.message || error.message, + }); + } finally { + setSaving(false); + } + }; + + // 插入变量 + const handleInsertVariable = (variable: TemplateVariable) => { + const currentTemplate = watchedTemplate || ''; + const variableText = `\${${variable.name}}`; + setValue('contentTemplate', currentTemplate + variableText); + }; + + return ( + + + + + {mode === 'edit' ? '编辑' : '创建'}通知模板 + + + + + {loading ? ( +
+ + 加载中... +
+ ) : ( +
+ {/* 基础信息 */} +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + + {mode === 'edit' && ( +

+ 编辑时不可修改模板编码 +

+ )} + {errors.code && ( +

{errors.code.message}

+ )} +
+
+ +
+
+ + + {mode === 'edit' && ( +

+ 编辑时不可修改渠道类型 +

+ )} + {errors.channelType && ( +

{errors.channelType.message}

+ )} +
+ +
+ + + {errors.description && ( +

{errors.description.message}

+ )} +
+
+ + {/* 模板编辑器区域 */} +
+ {/* 编辑器 */} +
+
+ +
+ +
+
+ +
+ setValue('contentTemplate', value)} + channelType={watchedChannelType} + variables={variables} + onVariableInsert={handleInsertVariable} + /> +
+ + {errors.contentTemplate && ( +

+ {errors.contentTemplate.message} +

+ )} +
+ + {/* 预览面板 */} + {showPreview && ( +
+ +
+ +
+
+ )} +
+
+ )} +
+ + {!loading && ( + + + + + )} +
+
+ ); +}; + +export default NotificationTemplateDialog; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/components/TemplateEditor.tsx b/frontend/src/pages/Deploy/NotificationTemplate/List/components/TemplateEditor.tsx new file mode 100644 index 00000000..1e6a4b91 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/components/TemplateEditor.tsx @@ -0,0 +1,279 @@ +/** + * 模板编辑器组件 + * 基于 Monaco Editor 实现,支持 FreeMarker 语法 + */ +import React, { useEffect, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Plus, Info } from 'lucide-react'; +import Editor from '@/components/Editor'; +import { NotificationChannelType } from '../../../NotificationChannel/List/types'; +import type { TemplateVariable } from '../types'; +import type { editor } from 'monaco-editor'; + +interface TemplateEditorProps { + value: string; + onChange: (value: string) => void; + channelType: NotificationChannelType; + variables: TemplateVariable[]; + onVariableInsert: (variable: TemplateVariable) => void; +} + +export const TemplateEditor: React.FC = ({ + value, + onChange, + channelType, + variables, + onVariableInsert, +}) => { + const editorRef = useRef(null); + + // 注册 FreeMarker 语言支持 + useEffect(() => { + const registerFreeMarker = async () => { + const monaco = await import('monaco-editor'); + + // 注册 FreeMarker 语言 + if (!monaco.languages.getLanguages().find(lang => lang.id === 'freemarker')) { + monaco.languages.register({ id: 'freemarker' }); + + // 设置 FreeMarker 语法高亮 + monaco.languages.setMonarchTokensProvider('freemarker', { + keywords: [ + 'if', 'else', 'elseif', 'endif', + 'list', 'endlist', 'items', + 'macro', 'endmacro', 'return', + 'function', 'endfunction', + 'assign', 'local', 'global', + 'include', 'import', 'nested', + 'compress', 'endcompress', + 'escape', 'noescape', 'endescape', + 'attempt', 'recover', 'endattempt', + 'switch', 'case', 'default', 'endswitch', + 'visit', 'recurse', 'fallback', + 'setting', 'flush', 'stop', + 'lt', 'lte', 'gt', 'gte' + ], + + operators: [ + '=', '>', '<', '!', '~', '?', ':', + '==', '<=', '>=', '!=', '&&', '||', '++', '--', + '+', '-', '*', '/', '&', '|', '^', '%', '<<', + '>>', '>>>', '+=', '-=', '*=', '/=', '&=', '|=', + '^=', '%=', '<<=', '>>=', '>>>=' + ], + + symbols: /[=>/, 'keyword'], + [/<#--/, 'comment', '@comment'], + + // FreeMarker 表达式 + [/\$\{/, 'delimiter', '@expression'], + + // 字符串 + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/"/, 'string', '@string'], + [/'([^'\\]|\\.)*$/, 'string.invalid'], + [/'/, 'string', '@string_single'], + + // 数字 + [/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'], + [/\d+/, 'number'], + + // 标识符和关键字 + [/[a-zA-Z_$][\w$]*/, { + cases: { + '@keywords': 'keyword', + '@default': 'identifier' + } + }], + + // 操作符 + [/@symbols/, { + cases: { + '@operators': 'operator', + '@default': '' + } + }], + + // 空白字符 + [/[ \t\r\n]+/, 'white'], + ], + + comment: [ + [/[^<-]+/, 'comment'], + [/-->/, 'comment', '@pop'], + [/[<-]/, 'comment'] + ], + + expression: [ + [/[^}]+/, 'variable'], + [/}/, 'delimiter', '@pop'] + ], + + string: [ + [/[^\\"]+/, 'string'], + [/\\./, 'string.escape'], + [/"/, 'string', '@pop'] + ], + + string_single: [ + [/[^\\']+/, 'string'], + [/\\./, 'string.escape'], + [/'/, 'string', '@pop'] + ] + } + }); + + // 设置自动完成 + monaco.languages.registerCompletionItemProvider('freemarker', { + provideCompletionItems: (model, position) => { + const suggestions = [ + // FreeMarker 指令 + ...['if', 'list', 'assign', 'include', 'macro'].map(keyword => ({ + label: `<#${keyword}>`, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: `<#${keyword} >\n`, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: `FreeMarker ${keyword} directive` + })), + + // 变量建议 + ...variables.map(variable => ({ + label: `\${${variable.name}}`, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: `\${${variable.name}}`, + documentation: variable.description, + detail: `${variable.type} - ${variable.required ? 'Required' : 'Optional'}` + })) + ]; + + return { suggestions }; + } + }); + } + }; + + registerFreeMarker(); + }, [variables]); + + const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => { + editorRef.current = editor; + }; + + const getVariableTypeColor = (type: string) => { + switch (type) { + case 'string': return 'bg-blue-100 text-blue-800'; + case 'number': return 'bg-green-100 text-green-800'; + case 'boolean': return 'bg-purple-100 text-purple-800'; + case 'object': return 'bg-orange-100 text-orange-800'; + case 'array': return 'bg-pink-100 text-pink-800'; + default: return 'bg-gray-100 text-gray-800'; + } + }; + + return ( +
+ {/* 编辑器 */} +
+ 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', + }} + /> +
+ + {/* 变量面板 */} +
+
+

可用变量

+

+ 点击变量名插入到模板中 +

+
+ + +
+ {variables.length === 0 ? ( +
+ +

暂无可用变量

+
+ ) : ( + variables.map((variable) => ( +
+
+
+ + {variable.required && ( + + 必填 + + )} +
+ + {variable.type} + +
+ +

+ {variable.description} +

+ + {variable.example !== undefined && ( +
+ 示例: + + {typeof variable.example === 'string' + ? variable.example + : JSON.stringify(variable.example) + } + +
+ )} +
+ )) + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/components/TemplatePreview.tsx b/frontend/src/pages/Deploy/NotificationTemplate/List/components/TemplatePreview.tsx new file mode 100644 index 00000000..67d891e0 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/components/TemplatePreview.tsx @@ -0,0 +1,160 @@ +/** + * 模板预览组件 + */ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, RefreshCw, AlertCircle } from 'lucide-react'; +import { NotificationChannelType } from '../../../NotificationChannel/List/types'; +import type { TemplateVariable } from '../types'; +import { previewTemplate } from '../service'; + +interface TemplatePreviewProps { + template: string; + channelType: NotificationChannelType; + variables: TemplateVariable[]; +} + +export const TemplatePreview: React.FC = ({ + template, + channelType, + variables, +}) => { + const [loading, setLoading] = useState(false); + const [previewContent, setPreviewContent] = useState(''); + const [error, setError] = useState(null); + const [mockData, setMockData] = useState>({}); + + // 初始化模拟数据 + useEffect(() => { + const initialMockData: Record = {}; + variables.forEach(variable => { + // 使用示例值或生成简单的默认值 + initialMockData[variable.name] = variable.example || `示例${variable.name}`; + }); + setMockData(initialMockData); + }, [variables]); + + // 预览模板 + const handlePreview = async () => { + if (!template.trim()) { + setPreviewContent(''); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await previewTemplate({ + contentTemplate: template, + channelType, + mockData, + }); + + if (response.success) { + setPreviewContent(response.content); + } else { + setError(response.error || '预览失败'); + setPreviewContent(''); + } + } catch (error: any) { + setError(error.response?.data?.message || error.message || '预览失败'); + setPreviewContent(''); + } finally { + setLoading(false); + } + }; + + // 自动预览 + useEffect(() => { + const timer = setTimeout(() => { + handlePreview(); + }, 500); + + return () => clearTimeout(timer); + }, [template, mockData, channelType]); + + // 更新模拟数据 + const updateMockData = (key: string, value: any) => { + setMockData(prev => ({ + ...prev, + [key]: value, + })); + }; + + return ( +
+ {/* 模拟数据输入 */} +
+
+

模拟数据

+ +
+ + +
+ {variables.map((variable) => ( +
+ + { + updateMockData(variable.name, e.target.value); + }} + /> +
+ ))} +
+
+
+ + {/* 预览内容 */} +
+
+

预览结果

+
+ +
+ {loading ? ( +
+ + 渲染中... +
+ ) : error ? ( + + + {error} + + ) : ( + +
+ {previewContent || '请输入模板内容进行预览'} +
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/components/index.ts b/frontend/src/pages/Deploy/NotificationTemplate/List/components/index.ts new file mode 100644 index 00000000..6ad70ba0 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/components/index.ts @@ -0,0 +1,6 @@ +/** + * 通知模板组件导出 + */ +export { NotificationTemplateDialog } from './NotificationTemplateDialog'; +export { TemplateEditor } from './TemplateEditor'; +export { TemplatePreview } from './TemplatePreview'; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/examples/usage.ts b/frontend/src/pages/Deploy/NotificationTemplate/List/examples/usage.ts new file mode 100644 index 00000000..f40e95a0 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/examples/usage.ts @@ -0,0 +1,109 @@ +/** + * 通知模板使用示例 + */ +import { renderTemplate } from '../service'; +import type { TemplateRenderRequest } from '../types'; + +/** + * 渲染Jenkins构建通知模板示例 + */ +export const renderJenkinsBuildNotification = async () => { + const request: TemplateRenderRequest = { + templateCode: 'jenkins_build_wework', + params: { + projectName: 'deploy-ease-platform', + buildNumber: 123, + buildStatus: 'SUCCESS', + buildUrl: 'http://jenkins.example.com/job/deploy-ease-platform/123/', + duration: '2m 30s', + commitId: 'abc123def', + commitMessage: 'feat: 添加通知模板功能', + author: 'developer@example.com', + timestamp: new Date().toISOString(), + }, + }; + + try { + const response = await renderTemplate(request); + if (response.success) { + console.log('渲染成功:', response.content); + return response.content; + } else { + console.error('渲染失败:', response.error); + throw new Error(response.error); + } + } catch (error) { + console.error('调用渲染接口失败:', error); + throw error; + } +}; + +/** + * 渲染部署成功通知模板示例 + */ +export const renderDeploySuccessNotification = async () => { + const request: TemplateRenderRequest = { + templateCode: 'deploy_success_email', + params: { + applicationName: '部署平台', + environment: '生产环境', + version: 'v1.2.3', + deployTime: new Date().toLocaleString('zh-CN'), + deployUrl: 'https://deploy.example.com', + operator: '运维团队', + releaseNotes: [ + '新增通知模板管理功能', + '优化部署流程', + '修复已知问题', + ], + }, + }; + + try { + const response = await renderTemplate(request); + if (response.success) { + return response.content; + } else { + throw new Error(response.error); + } + } catch (error) { + console.error('渲染部署通知失败:', error); + throw error; + } +}; + +/** + * 在其他业务模块中使用模板渲染 + * + * 使用方式: + * 1. 导入渲染函数 + * 2. 准备模板编码和参数 + * 3. 调用渲染接口 + * 4. 处理渲染结果 + */ +export const useTemplateInBusiness = async ( + templateCode: string, + businessData: Record +) => { + const request: TemplateRenderRequest = { + templateCode, + params: businessData, + }; + + const response = await renderTemplate(request); + + if (response.success) { + // 渲染成功,可以发送通知 + return { + success: true, + content: response.content, + }; + } else { + // 渲染失败,记录错误 + console.error(`模板 ${templateCode} 渲染失败:`, response.error); + return { + success: false, + error: response.error, + }; + } +}; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/index.tsx b/frontend/src/pages/Deploy/NotificationTemplate/List/index.tsx new file mode 100644 index 00000000..a418fc2a --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/index.tsx @@ -0,0 +1,475 @@ +/** + * 通知模板管理页面 + */ +import React, { useState, useEffect } from 'react'; +import { PageContainer } from '@/components/ui/page-container'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useToast } from '@/components/ui/use-toast'; +import { DataTablePagination } from '@/components/ui/pagination'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page'; +import { + Plus, + Search, + Loader2, + Edit, + Trash2, + Power, + PowerOff, + Activity, + Database, + Server, + MoreHorizontal +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { NotificationChannelType } from '../../NotificationChannel/List/types'; +import type { + NotificationTemplateDTO, + NotificationTemplateQuery, + ChannelTypeOption +} from './types'; +import { + getTemplateList, + deleteTemplate, + enableTemplate, + disableTemplate, +} from './service'; +import { NotificationTemplateDialog } from './components'; + +// 渠道类型选项 +const CHANNEL_TYPE_OPTIONS: ChannelTypeOption[] = [ + { + code: NotificationChannelType.WEWORK, + label: '企业微信', + description: '企业微信机器人通知', + }, + { + code: NotificationChannelType.EMAIL, + label: '邮件', + description: '邮件通知', + }, +]; + +const NotificationTemplateList: React.FC = () => { + const { toast } = useToast(); + + // 状态管理 + const [loading, setLoading] = useState(false); + const [list, setList] = useState([]); + const [pagination, setPagination] = useState({ + pageNum: DEFAULT_CURRENT, + pageSize: DEFAULT_PAGE_SIZE, + totalElements: 0, + }); + + // 统计数据 + const [stats, setStats] = useState({ + total: 0, + enabled: 0, + disabled: 0, + }); + + // 操作状态管理 + const [operationLoading, setOperationLoading] = useState({ + toggling: null as number | null, + deleting: null as number | null, + }); + + // 查询条件 + const [query, setQuery] = useState({}); + + // 对话框状态 + const [modalVisible, setModalVisible] = useState(false); + const [currentTemplate, setCurrentTemplate] = useState(); + + // 确认对话框 + const [confirmDialog, setConfirmDialog] = useState<{ + open: boolean; + title: string; + description: string; + onConfirm: () => Promise; + }>({ + open: false, + title: '', + description: '', + onConfirm: async () => {}, + }); + + // 加载数据 + const loadData = async (params?: NotificationTemplateQuery) => { + setLoading(true); + try { + const queryParams: NotificationTemplateQuery = { + ...query, + ...params, + pageNum: pagination.pageNum, + pageSize: pagination.pageSize, + }; + const data = await getTemplateList(queryParams); + setList(data.content || []); + setPagination({ + ...pagination, + totalElements: data.totalElements, + }); + + // 计算统计数据 + const all = data.content || []; + setStats({ + total: all.length, + enabled: all.filter((item) => item.enabled).length, + disabled: all.filter((item) => !item.enabled).length, + }); + } catch (error: any) { + toast({ + variant: 'destructive', + title: '获取模板列表失败', + description: error.response?.data?.message || error.message, + duration: 3000, + }); + } finally { + setLoading(false); + } + }; + + // 分页处理 + const handlePageChange = (page: number) => { + setPagination({ + ...pagination, + pageNum: page, + }); + }; + + // 初始加载 + useEffect(() => { + loadData(); + }, [pagination.pageNum, pagination.pageSize]); + + // 搜索处理 + const handleSearch = () => { + setPagination(prev => ({ ...prev, pageNum: DEFAULT_CURRENT })); + loadData(); + }; + + // 新建模板 + const handleAdd = () => { + setCurrentTemplate(undefined); + setModalVisible(true); + }; + + // 编辑模板 + const handleEdit = (template: NotificationTemplateDTO) => { + setCurrentTemplate(template); + setModalVisible(true); + }; + + // 删除模板 + const handleDelete = (template: NotificationTemplateDTO) => { + setConfirmDialog({ + open: true, + title: '删除模板', + description: `确定要删除模板 "${template.name}" 吗?此操作不可恢复。`, + onConfirm: async () => { + try { + await deleteTemplate(template.id); + toast({ title: '删除成功' }); + loadData(); + } catch (error: any) { + toast({ + variant: 'destructive', + title: '删除失败', + description: error.response?.data?.message || error.message, + }); + } + setConfirmDialog(prev => ({ ...prev, open: false })); + }, + }); + }; + + // 启用/禁用模板 + const handleToggleStatus = async (template: NotificationTemplateDTO) => { + setOperationLoading(prev => ({ ...prev, toggling: template.id })); + try { + if (template.enabled) { + await disableTemplate(template.id); + toast({ title: '已禁用' }); + } else { + await enableTemplate(template.id); + toast({ title: '已启用' }); + } + loadData(); + } catch (error: any) { + toast({ + variant: 'destructive', + title: '操作失败', + description: error.response?.data?.message || error.message, + }); + } finally { + setOperationLoading(prev => ({ ...prev, toggling: null })); + } + }; + + // 获取渠道类型标签 + const getChannelTypeLabel = (type: NotificationChannelType) => { + const option = CHANNEL_TYPE_OPTIONS.find(opt => opt.code === type); + return option?.label || type; + }; + + return ( + + {/* 统计卡片 */} +
+ + + 总模板数 + + + +
{stats.total}
+

所有模板

+
+
+ + + 已启用 + + + +
{stats.enabled}
+

正在使用中

+
+
+ + + 已禁用 + + + +
{stats.disabled}
+

暂时停用

+
+
+
+ + {/* 模板管理 */} + + +
+
+ 通知模板 + + 管理通知消息模板,支持 FreeMarker 语法 + +
+ +
+
+ + {/* 搜索区域 */} +
+
+ setQuery(prev => ({ ...prev, name: e.target.value }))} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSearch(); + } + }} + /> +
+ + +
+ + {/* 表格 */} +
+ + + + 模板名称 + 模板编码 + 渠道类型 + 状态 + 创建时间 + 操作 + + + + {loading ? ( + + + + + + ) : list.length === 0 ? ( + + + 暂无数据 + + + ) : ( + list.map((template) => ( + + {template.name} + {template.code} + + + {getChannelTypeLabel(template.channelType)} + + + + + {template.enabled ? "启用" : "禁用"} + + + {template.createTime} + +
+ + {template.enabled ? ( + + ) : ( + + )} + +
+
+
+ )) + )} +
+
+
+ + {/* 分页 */} +
+ +
+
+
+ + {/* 对话框 */} + + + {/* 确认对话框 */} + setConfirmDialog(prev => ({ ...prev, open }))} + /> +
+ ); +}; + +export default NotificationTemplateList; diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/service.ts b/frontend/src/pages/Deploy/NotificationTemplate/List/service.ts new file mode 100644 index 00000000..7de751c9 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/service.ts @@ -0,0 +1,155 @@ +/** + * 通知模板API服务 + * + * API接口列表: + * - GET /api/v1/notification-template/page - 获取模板列表(分页) + * - GET /api/v1/notification-template/{id} - 获取模板详情 + * - POST /api/v1/notification-template - 创建模板 + * - PUT /api/v1/notification-template/{id} - 更新模板 + * - DELETE /api/v1/notification-template/{id} - 删除模板 + * - POST /api/v1/notification-template/render - 渲染模板 + * - POST /api/v1/notification-template/preview - 预览模板 + * - POST /api/v1/notification-template/{id}/copy - 复制模板 + * - PUT /api/v1/notification-template/{id}/enable - 启用模板 + * - PUT /api/v1/notification-template/{id}/disable - 禁用模板 + * - GET /api/v1/notification-template/check-code - 检查编码唯一性 + */ +import request from '../../../../utils/request'; +import type { Page } from '../../../../types/base'; +import type { + NotificationTemplateDTO, + NotificationTemplateQuery, + TemplateRenderRequest, + TemplatePreviewRequest, + TemplatePreviewResponse, +} from './types'; + +const API_PREFIX = '/api/v1/notification-template'; + +/** + * 获取模板列表(分页) + */ +export const getTemplateList = async ( + params: NotificationTemplateQuery +): Promise> => { + return request.get(`${API_PREFIX}/page`, { params }); +}; + +/** + * 根据ID获取模板详情 + */ +export const getTemplateById = async (id: number): Promise => { + return request.get(`${API_PREFIX}/${id}`); +}; + +/** + * 创建模板 + */ +export const createTemplate = async ( + template: Pick +): Promise => { + return request.post(`${API_PREFIX}`, { + ...template, + enabled: true, // 默认启用 + }); +}; + +/** + * 更新模板 + */ +export const updateTemplate = async ( + id: number, + template: Pick +): Promise => { + return request.put(`${API_PREFIX}/${id}`, template); +}; + +/** + * 删除模板 + */ +export const deleteTemplate = async (id: number): Promise => { + return request.delete(`${API_PREFIX}/${id}`); +}; + +/** + * 批量删除模板 + */ +export const batchDeleteTemplates = async (ids: number[]): Promise => { + return request.delete(`${API_PREFIX}/batch`, { data: { ids } }); +}; + +/** + * 启用模板 + */ +export const enableTemplate = async (id: number): Promise => { + return request.put(`${API_PREFIX}/${id}/enable`); +}; + +/** + * 禁用模板 + */ +export const disableTemplate = async (id: number): Promise => { + return request.put(`${API_PREFIX}/${id}/disable`); +}; + +/** + * 批量启用模板 + */ +export const batchEnableTemplates = async (ids: number[]): Promise => { + return request.put(`${API_PREFIX}/batch/enable`, { ids }); +}; + +/** + * 批量禁用模板 + */ +export const batchDisableTemplates = async (ids: number[]): Promise => { + return request.put(`${API_PREFIX}/batch/disable`, { ids }); +}; + +/** + * 检查模板编码是否唯一 + */ +export const checkTemplateCodeUnique = async ( + code: string, + excludeId?: number +): Promise => { + return request.get(`${API_PREFIX}/check-code`, { + params: { code, excludeId }, + }); +}; + +/** + * 渲染模板(根据模板编码) + */ +export const renderTemplate = async ( + renderRequest: TemplateRenderRequest +): Promise => { + return request.post(`${API_PREFIX}/render`, renderRequest); +}; + +/** + * 预览模板渲染结果(编辑时使用) + */ +export const previewTemplate = async ( + previewRequest: TemplatePreviewRequest +): Promise => { + return request.post(`${API_PREFIX}/preview`, previewRequest); +}; + +// 模板变量可以在前端硬编码定义,不需要额外的API接口 + +/** + * 复制模板 + */ +export const copyTemplate = async ( + id: number, + newName: string, + newCode: string +): Promise => { + return request.post(`${API_PREFIX}/${id}/copy`, { + name: newName, + code: newCode, + }); +}; + +// renderTemplate 函数已在上面定义并自动导出 diff --git a/frontend/src/pages/Deploy/NotificationTemplate/List/types.ts b/frontend/src/pages/Deploy/NotificationTemplate/List/types.ts new file mode 100644 index 00000000..eae766c5 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/List/types.ts @@ -0,0 +1,101 @@ +/** + * 通知模板相关类型定义 + */ +import type { BaseResponse, BaseQuery, Page } from '../../../../types/base'; +import { NotificationChannelType } from '../../NotificationChannel/List/types'; + +// ============================================ +// 1. 通知模板DTO +// ============================================ +export interface NotificationTemplateDTO extends BaseResponse { + /** 模板名称 */ + name: string; + + /** 模板编码(唯一标识) */ + code: string; + + /** 模板描述 */ + description?: string; + + /** 渠道类型 */ + channelType: NotificationChannelType; + + /** 内容模板(FreeMarker格式) */ + contentTemplate: string; +} + +// ============================================ +// 2. 查询参数 +// ============================================ +export interface NotificationTemplateQuery extends BaseQuery { + /** 模板名称(模糊查询) */ + name?: string; + + /** 模板编码 */ + code?: string; + + /** 渠道类型 */ + channelType?: NotificationChannelType; + + /** 启用状态 */ + enabled?: boolean; +} + +// ============================================ +// 5. 简化的模板变量定义(前端硬编码) +// ============================================ +export interface TemplateVariable { + /** 变量名 */ + name: string; + + /** 变量描述 */ + description: string; + + /** 示例值 */ + example?: string; +} + +// ============================================ +// 6. 渠道类型选项 +// ============================================ +export interface ChannelTypeOption { + code: NotificationChannelType; + label: string; + description: string; +} + +// ============================================ +// 7. 模板渲染参数 +// ============================================ +export interface TemplateRenderRequest { + /** 模板编码 */ + templateCode: string; + + /** 模板参数 */ + params: Record; +} + +// ============================================ +// 8. 模板预览参数(用于编辑时预览) +// ============================================ +export interface TemplatePreviewRequest { + /** 模板内容 */ + contentTemplate: string; + + /** 渠道类型 */ + channelType: NotificationChannelType; + + /** 模拟数据 */ + mockData: Record; +} + +export interface TemplatePreviewResponse { + /** 渲染结果 */ + content: string; + + /** 是否成功 */ + success: boolean; + + /** 错误信息 */ + error?: string; +} diff --git a/frontend/src/pages/Deploy/NotificationTemplate/index.ts b/frontend/src/pages/Deploy/NotificationTemplate/index.ts new file mode 100644 index 00000000..4e358277 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationTemplate/index.ts @@ -0,0 +1,15 @@ +/** + * 通知模板模块导出 + */ +export { default as NotificationTemplateList } from './List'; +export type * from './List/types'; + +// 导出API服务供其他模块使用 +export { + renderTemplate, + getTemplateList, + getTemplateById, + createTemplate, + updateTemplate, + deleteTemplate, +} from './List/service';