重构消息通知弹窗
This commit is contained in:
parent
13f8be9146
commit
c00153082c
@ -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>(
|
||||
NotificationChannelType.WEWORK
|
||||
);
|
||||
const [currentStatus, setCurrentStatus] = useState<NotificationChannelStatus | undefined>();
|
||||
const [currentEnabled, setCurrentEnabled] = useState<boolean>(true);
|
||||
const [configState, setConfigState] = useState<ConfigState>({});
|
||||
|
||||
// 表单管理
|
||||
@ -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,15 +135,9 @@ 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);
|
||||
toast({ title: '更新成功' });
|
||||
|
||||
@ -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<NotificationChannelQuery>({
|
||||
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 <Badge variant="default">启用</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">禁用</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">消息中心</h1>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建渠道
|
||||
</Button>
|
||||
<PageContainer>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">总渠道数</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{channels.length}</div>
|
||||
<p className="text-xs text-muted-foreground">所有渠道</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已启用</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{channels.filter(c => c.enabled).length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">正在使用中</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已禁用</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{channels.filter(c => !c.enabled).length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">暂时停用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 搜索筛选 */}
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">渠道名称</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索渠道名称"
|
||||
value={query.name}
|
||||
onChange={(e) => setQuery({ ...query, name: e.target.value })}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="pl-10"
|
||||
{/* 渠道管理 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>通知渠道</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
管理通知渠道配置,支持企业微信、邮件等多种渠道
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建渠道
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 搜索区域 */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索渠道名称"
|
||||
value={query.name}
|
||||
onChange={(e) => setQuery({ ...query, name: e.target.value })}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={query.channelType || 'all'}
|
||||
onValueChange={(value) =>
|
||||
setQuery({
|
||||
...query,
|
||||
channelType:
|
||||
value === 'all' ? undefined : (value as NotificationChannelType),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="选择渠道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{channelTypes
|
||||
.filter((type) => type.code && type.code.trim() !== '')
|
||||
.map((type) => (
|
||||
<SelectItem key={type.code} value={type.code}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>渠道名称</TableHead>
|
||||
<TableHead>渠道类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : channels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell className="font-medium">{channel.name}</TableCell>
|
||||
<TableCell>
|
||||
{getChannelTypeBadge(channel.channelType)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={channel.enabled ? "default" : "secondary"}>
|
||||
{channel.enabled ? "启用" : "禁用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{channel.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>{channel.createTime}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleTest(channel.id!)}
|
||||
disabled={testing === channel.id}
|
||||
title="测试连接"
|
||||
>
|
||||
{testing === channel.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{channel.enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleToggleStatus(channel.id!, false)}
|
||||
title="禁用"
|
||||
>
|
||||
<PowerOff className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleToggleStatus(channel.id!, true)}
|
||||
title="启用"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(channel.id!)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteId(channel.id!);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
currentPage={pagination.pageNum}
|
||||
totalPages={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
||||
totalItems={pagination.totalElements}
|
||||
pageSize={pagination.pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-48 space-y-2">
|
||||
<label className="text-sm font-medium">渠道类型</label>
|
||||
<Select
|
||||
value={query.channelType || 'all'}
|
||||
onValueChange={(value) =>
|
||||
setQuery({
|
||||
...query,
|
||||
channelType:
|
||||
value === 'all' ? undefined : (value as NotificationChannelType),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{channelTypes.map((type) => (
|
||||
<SelectItem key={type.code} value={type.code}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-32 space-y-2">
|
||||
<label className="text-sm font-medium">状态</label>
|
||||
<Select
|
||||
value={query.status || 'all'}
|
||||
onValueChange={(value) =>
|
||||
setQuery({
|
||||
...query,
|
||||
status:
|
||||
value === 'all' ? undefined : (value as NotificationChannelStatus),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value={NotificationChannelStatus.ENABLED}>启用</SelectItem>
|
||||
<SelectItem value={NotificationChannelStatus.DISABLED}>禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
搜索
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead>渠道名称</TableHead>
|
||||
<TableHead className="w-32">渠道类型</TableHead>
|
||||
<TableHead className="w-24">状态</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="w-44">创建时间</TableHead>
|
||||
<TableHead className="w-64">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : channels.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
channels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>{channel.id}</TableCell>
|
||||
<TableCell className="font-medium">{channel.name}</TableCell>
|
||||
<TableCell>{getChannelTypeBadge(channel.channelType)}</TableCell>
|
||||
<TableCell>{getStatusBadge(channel.status)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{channel.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>{channel.createTime}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleTest(channel.id!)}
|
||||
disabled={testing === channel.id}
|
||||
title="测试连接"
|
||||
>
|
||||
{testing === channel.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{channel.status === NotificationChannelStatus.ENABLED ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleToggleStatus(channel.id!, false)}
|
||||
title="禁用"
|
||||
>
|
||||
<PowerOff className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleToggleStatus(channel.id!, true)}
|
||||
title="启用"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(channel.id!)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteId(channel.id!);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{!loading && channels.length > 0 && (
|
||||
<div className="flex justify-end border-t border-border bg-muted/40">
|
||||
<DataTablePagination
|
||||
pageIndex={pagination.pageNum - 1}
|
||||
pageSize={pagination.pageSize}
|
||||
pageCount={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑对话框 */}
|
||||
<NotificationChannelDialog
|
||||
@ -471,7 +505,7 @@ const NotificationChannelList: React.FC = () => {
|
||||
variant="destructive"
|
||||
confirmText="删除"
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<typeof formSchema>;
|
||||
|
||||
export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProps> = ({
|
||||
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<TemplateVariable[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
setError,
|
||||
clearErrors,
|
||||
} = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' ? '编辑' : '创建'}通知模板
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex-1 overflow-hidden">
|
||||
{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">
|
||||
{/* 基础信息 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
模板名称 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="例如:部署成功通知"
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">
|
||||
模板编码 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder="例如:deploy_success"
|
||||
disabled={mode === 'edit'}
|
||||
{...register('code')}
|
||||
/>
|
||||
{mode === 'edit' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
编辑时不可修改模板编码
|
||||
</p>
|
||||
)}
|
||||
{errors.code && (
|
||||
<p className="text-sm text-destructive">{errors.code.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channelType">
|
||||
渠道类型 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={watchedChannelType}
|
||||
onValueChange={(value) => setValue('channelType', value as NotificationChannelType)}
|
||||
disabled={mode === 'edit'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择渠道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channelTypes.map((type) => (
|
||||
<SelectItem key={type.code} value={type.code}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'edit' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
编辑时不可修改渠道类型
|
||||
</p>
|
||||
)}
|
||||
{errors.channelType && (
|
||||
<p className="text-sm text-destructive">{errors.channelType.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述(可选)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="模板用途说明"
|
||||
{...register('description')}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-sm text-destructive">{errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模板编辑器区域 */}
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* 编辑器 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>
|
||||
模板内容 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{showPreview ? '隐藏预览' : '显示预览'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<TemplateEditor
|
||||
value={watchedTemplate}
|
||||
onChange={(value) => setValue('contentTemplate', value)}
|
||||
channelType={watchedChannelType}
|
||||
variables={variables}
|
||||
onVariableInsert={handleInsertVariable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.contentTemplate && (
|
||||
<p className="text-sm text-destructive mt-2">
|
||||
{errors.contentTemplate.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预览面板 */}
|
||||
{showPreview && (
|
||||
<div className="w-1/2 flex flex-col min-w-0">
|
||||
<Label className="mb-2">预览</Label>
|
||||
<div className="flex-1 min-h-0">
|
||||
<TemplatePreview
|
||||
template={watchedTemplate}
|
||||
channelType={watchedChannelType}
|
||||
variables={variables}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
{!loading && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !isValid}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'edit' ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTemplateDialog;
|
||||
@ -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<TemplateEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
channelType,
|
||||
variables,
|
||||
onVariableInsert,
|
||||
}) => {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(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: /[=><!~?:&|+\-*\/\^%]+/,
|
||||
|
||||
tokenizer: {
|
||||
root: [
|
||||
// FreeMarker 指令
|
||||
[/<#[a-zA-Z_][a-zA-Z0-9_]*/, 'keyword'],
|
||||
[/<\/#[a-zA-Z_][a-zA-Z0-9_]*>/, '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</#${keyword}>`,
|
||||
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 (
|
||||
<div className="flex h-full gap-4">
|
||||
{/* 编辑器 */}
|
||||
<div className="flex-1 border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="freemarker"
|
||||
theme="vs-dark"
|
||||
value={value}
|
||||
onChange={(val) => onChange(val || '')}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
folding: true,
|
||||
bracketMatching: 'always',
|
||||
autoIndent: 'full',
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
acceptSuggestionOnEnter: 'on',
|
||||
tabCompletion: 'on',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 变量面板 */}
|
||||
<div className="w-80 border rounded-lg flex flex-col">
|
||||
<div className="p-3 border-b bg-muted/50">
|
||||
<h3 className="font-medium">可用变量</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
点击变量名插入到模板中
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-3">
|
||||
{variables.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Info className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">暂无可用变量</p>
|
||||
</div>
|
||||
) : (
|
||||
variables.map((variable) => (
|
||||
<div
|
||||
key={variable.name}
|
||||
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 font-mono text-sm"
|
||||
onClick={() => onVariableInsert(variable)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
${variable.name}
|
||||
</Button>
|
||||
{variable.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
必填
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${getVariableTypeColor(variable.type)}`}
|
||||
>
|
||||
{variable.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{variable.description}
|
||||
</p>
|
||||
|
||||
{variable.example !== undefined && (
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">示例: </span>
|
||||
<code className="bg-muted px-1 py-0.5 rounded">
|
||||
{typeof variable.example === 'string'
|
||||
? variable.example
|
||||
: JSON.stringify(variable.example)
|
||||
}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<TemplatePreviewProps> = ({
|
||||
template,
|
||||
channelType,
|
||||
variables,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mockData, setMockData] = useState<Record<string, any>>({});
|
||||
|
||||
// 初始化模拟数据
|
||||
useEffect(() => {
|
||||
const initialMockData: Record<string, any> = {};
|
||||
variables.forEach(variable => {
|
||||
// 使用示例值或生成简单的默认值
|
||||
initialMockData[variable.name] = variable.example || `示例${variable.name}`;
|
||||
});
|
||||
setMockData(initialMockData);
|
||||
}, [variables]);
|
||||
|
||||
// 预览模板
|
||||
const handlePreview = async () => {
|
||||
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 (
|
||||
<div className="h-full flex flex-col border rounded-lg">
|
||||
{/* 模拟数据输入 */}
|
||||
<div className="p-3 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">模拟数据</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreview}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-32">
|
||||
<div className="space-y-2">
|
||||
{variables.map((variable) => (
|
||||
<div key={variable.name} className="flex items-center gap-2">
|
||||
<Label className="text-xs min-w-0 flex-shrink-0">
|
||||
{variable.name}:
|
||||
</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder={variable.description}
|
||||
value={mockData[variable.name] || ''}
|
||||
onChange={(e) => {
|
||||
updateMockData(variable.name, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 预览内容 */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="p-3 border-b">
|
||||
<h3 className="font-medium">预览结果</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
渲染中...
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-3 rounded border">
|
||||
{previewContent || '请输入模板内容进行预览'}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 通知模板组件导出
|
||||
*/
|
||||
export { NotificationTemplateDialog } from './NotificationTemplateDialog';
|
||||
export { TemplateEditor } from './TemplateEditor';
|
||||
export { TemplatePreview } from './TemplatePreview';
|
||||
@ -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<string, any>
|
||||
) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
475
frontend/src/pages/Deploy/NotificationTemplate/List/index.tsx
Normal file
475
frontend/src/pages/Deploy/NotificationTemplate/List/index.tsx
Normal file
@ -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<NotificationTemplateDTO[]>([]);
|
||||
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<NotificationTemplateQuery>({});
|
||||
|
||||
// 对话框状态
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplateDTO | undefined>();
|
||||
|
||||
// 确认对话框
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
onConfirm: () => Promise<void>;
|
||||
}>({
|
||||
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 (
|
||||
<PageContainer>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">总模板数</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
<p className="text-xs text-muted-foreground">所有模板</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已启用</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
|
||||
<p className="text-xs text-muted-foreground">正在使用中</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">已禁用</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
|
||||
<p className="text-xs text-muted-foreground">暂时停用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 模板管理 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>通知模板</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
管理通知消息模板,支持 FreeMarker 语法
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建模板
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 搜索区域 */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索模板名称或编码"
|
||||
value={query.name || ''}
|
||||
onChange={(e) => setQuery(prev => ({ ...prev, name: e.target.value }))}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={query.channelType || 'all'}
|
||||
onValueChange={(value) =>
|
||||
setQuery(prev => ({
|
||||
...prev,
|
||||
channelType: value === 'all' ? undefined : value as NotificationChannelType
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="选择渠道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{CHANNEL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模板名称</TableHead>
|
||||
<TableHead>模板编码</TableHead>
|
||||
<TableHead>渠道类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
list.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className="font-medium">{template.name}</TableCell>
|
||||
<TableCell>{template.code}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{getChannelTypeLabel(template.channelType)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={template.enabled ? "default" : "secondary"}>
|
||||
{template.enabled ? "启用" : "禁用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{template.createTime}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(template)}
|
||||
disabled={operationLoading.toggling === template.id || operationLoading.deleting === template.id}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{template.enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleToggleStatus(template)}
|
||||
disabled={operationLoading.toggling === template.id}
|
||||
title="禁用"
|
||||
>
|
||||
{operationLoading.toggling === template.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PowerOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleToggleStatus(template)}
|
||||
disabled={operationLoading.toggling === template.id}
|
||||
title="启用"
|
||||
>
|
||||
{operationLoading.toggling === template.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Power className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(template)}
|
||||
disabled={operationLoading.toggling === template.id || operationLoading.deleting === template.id}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
currentPage={pagination.pageNum}
|
||||
totalPages={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
||||
totalItems={pagination.totalElements}
|
||||
pageSize={pagination.pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 对话框 */}
|
||||
<NotificationTemplateDialog
|
||||
open={modalVisible}
|
||||
editId={currentTemplate?.id}
|
||||
channelTypes={CHANNEL_TYPE_OPTIONS}
|
||||
onOpenChange={setModalVisible}
|
||||
onSuccess={loadData}
|
||||
/>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<ConfirmDialog
|
||||
open={confirmDialog.open}
|
||||
title={confirmDialog.title}
|
||||
description={confirmDialog.description}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onOpenChange={(open) => setConfirmDialog(prev => ({ ...prev, open }))}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTemplateList;
|
||||
155
frontend/src/pages/Deploy/NotificationTemplate/List/service.ts
Normal file
155
frontend/src/pages/Deploy/NotificationTemplate/List/service.ts
Normal file
@ -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<Page<NotificationTemplateDTO>> => {
|
||||
return request.get(`${API_PREFIX}/page`, { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取模板详情
|
||||
*/
|
||||
export const getTemplateById = async (id: number): Promise<NotificationTemplateDTO> => {
|
||||
return request.get(`${API_PREFIX}/${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建模板
|
||||
*/
|
||||
export const createTemplate = async (
|
||||
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'>
|
||||
): Promise<NotificationTemplateDTO> => {
|
||||
return request.post(`${API_PREFIX}`, {
|
||||
...template,
|
||||
enabled: true, // 默认启用
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
export const updateTemplate = async (
|
||||
id: number,
|
||||
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'>
|
||||
): Promise<NotificationTemplateDTO> => {
|
||||
return request.put(`${API_PREFIX}/${id}`, template);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
export const deleteTemplate = async (id: number): Promise<void> => {
|
||||
return request.delete(`${API_PREFIX}/${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量删除模板
|
||||
*/
|
||||
export const batchDeleteTemplates = async (ids: number[]): Promise<void> => {
|
||||
return request.delete(`${API_PREFIX}/batch`, { data: { ids } });
|
||||
};
|
||||
|
||||
/**
|
||||
* 启用模板
|
||||
*/
|
||||
export const enableTemplate = async (id: number): Promise<void> => {
|
||||
return request.put(`${API_PREFIX}/${id}/enable`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 禁用模板
|
||||
*/
|
||||
export const disableTemplate = async (id: number): Promise<void> => {
|
||||
return request.put(`${API_PREFIX}/${id}/disable`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量启用模板
|
||||
*/
|
||||
export const batchEnableTemplates = async (ids: number[]): Promise<void> => {
|
||||
return request.put(`${API_PREFIX}/batch/enable`, { ids });
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量禁用模板
|
||||
*/
|
||||
export const batchDisableTemplates = async (ids: number[]): Promise<void> => {
|
||||
return request.put(`${API_PREFIX}/batch/disable`, { ids });
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查模板编码是否唯一
|
||||
*/
|
||||
export const checkTemplateCodeUnique = async (
|
||||
code: string,
|
||||
excludeId?: number
|
||||
): Promise<boolean> => {
|
||||
return request.get(`${API_PREFIX}/check-code`, {
|
||||
params: { code, excludeId },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染模板(根据模板编码)
|
||||
*/
|
||||
export const renderTemplate = async (
|
||||
renderRequest: TemplateRenderRequest
|
||||
): Promise<TemplatePreviewResponse> => {
|
||||
return request.post(`${API_PREFIX}/render`, renderRequest);
|
||||
};
|
||||
|
||||
/**
|
||||
* 预览模板渲染结果(编辑时使用)
|
||||
*/
|
||||
export const previewTemplate = async (
|
||||
previewRequest: TemplatePreviewRequest
|
||||
): Promise<TemplatePreviewResponse> => {
|
||||
return request.post(`${API_PREFIX}/preview`, previewRequest);
|
||||
};
|
||||
|
||||
// 模板变量可以在前端硬编码定义,不需要额外的API接口
|
||||
|
||||
/**
|
||||
* 复制模板
|
||||
*/
|
||||
export const copyTemplate = async (
|
||||
id: number,
|
||||
newName: string,
|
||||
newCode: string
|
||||
): Promise<NotificationTemplateDTO> => {
|
||||
return request.post(`${API_PREFIX}/${id}/copy`, {
|
||||
name: newName,
|
||||
code: newCode,
|
||||
});
|
||||
};
|
||||
|
||||
// renderTemplate 函数已在上面定义并自动导出
|
||||
101
frontend/src/pages/Deploy/NotificationTemplate/List/types.ts
Normal file
101
frontend/src/pages/Deploy/NotificationTemplate/List/types.ts
Normal file
@ -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<string, any>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 8. 模板预览参数(用于编辑时预览)
|
||||
// ============================================
|
||||
export interface TemplatePreviewRequest {
|
||||
/** 模板内容 */
|
||||
contentTemplate: string;
|
||||
|
||||
/** 渠道类型 */
|
||||
channelType: NotificationChannelType;
|
||||
|
||||
/** 模拟数据 */
|
||||
mockData: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TemplatePreviewResponse {
|
||||
/** 渲染结果 */
|
||||
content: string;
|
||||
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
15
frontend/src/pages/Deploy/NotificationTemplate/index.ts
Normal file
15
frontend/src/pages/Deploy/NotificationTemplate/index.ts
Normal file
@ -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';
|
||||
Loading…
Reference in New Issue
Block a user