重构消息通知弹窗
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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { NotificationChannelType, NotificationChannelStatus } from '../../types';
|
import { NotificationChannelType } from '../../types';
|
||||||
import type { NotificationChannel } from '../../types';
|
import type { NotificationChannel } from '../../types';
|
||||||
import type { ConfigState } from '../config-strategies/types';
|
import type { ConfigState } from '../config-strategies/types';
|
||||||
import { ConfigStrategyFactory } from '../config-strategies/ConfigStrategyFactory';
|
import { ConfigStrategyFactory } from '../config-strategies/ConfigStrategyFactory';
|
||||||
@ -33,7 +33,7 @@ export const useNotificationChannelForm = ({
|
|||||||
const [selectedType, setSelectedType] = useState<NotificationChannelType>(
|
const [selectedType, setSelectedType] = useState<NotificationChannelType>(
|
||||||
NotificationChannelType.WEWORK
|
NotificationChannelType.WEWORK
|
||||||
);
|
);
|
||||||
const [currentStatus, setCurrentStatus] = useState<NotificationChannelStatus | undefined>();
|
const [currentEnabled, setCurrentEnabled] = useState<boolean>(true);
|
||||||
const [configState, setConfigState] = useState<ConfigState>({});
|
const [configState, setConfigState] = useState<ConfigState>({});
|
||||||
|
|
||||||
// 表单管理
|
// 表单管理
|
||||||
@ -85,7 +85,7 @@ export const useNotificationChannelForm = ({
|
|||||||
|
|
||||||
setSelectedType(defaultType);
|
setSelectedType(defaultType);
|
||||||
setConfigState(strategy.loadFromData({ channelType: defaultType, config: {} }));
|
setConfigState(strategy.loadFromData({ channelType: defaultType, config: {} }));
|
||||||
setCurrentStatus(undefined);
|
setCurrentEnabled(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载编辑数据
|
// 加载编辑数据
|
||||||
@ -104,7 +104,7 @@ export const useNotificationChannelForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setSelectedType(data.channelType);
|
setSelectedType(data.channelType);
|
||||||
setCurrentStatus(data.status);
|
setCurrentEnabled(data.enabled);
|
||||||
|
|
||||||
// 使用策略加载配置状态
|
// 使用策略加载配置状态
|
||||||
const strategy = ConfigStrategyFactory.getStrategy(data.channelType);
|
const strategy = ConfigStrategyFactory.getStrategy(data.channelType);
|
||||||
@ -135,15 +135,9 @@ export const useNotificationChannelForm = ({
|
|||||||
channelType: values.channelType,
|
channelType: values.channelType,
|
||||||
config,
|
config,
|
||||||
description: values.description,
|
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) {
|
if (mode === 'edit' && editId) {
|
||||||
await updateChannel(editId, payload);
|
await updateChannel(editId, payload);
|
||||||
toast({ title: '更新成功' });
|
toast({ title: '更新成功' });
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { PageContainer } from '@/components/ui/page-container';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,13 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
@ -31,13 +39,24 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
|
Activity,
|
||||||
|
Database,
|
||||||
|
Server,
|
||||||
|
MoreHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import type {
|
import type {
|
||||||
NotificationChannel,
|
NotificationChannel,
|
||||||
NotificationChannelQuery,
|
NotificationChannelQuery,
|
||||||
ChannelTypeOption,
|
ChannelTypeOption,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { NotificationChannelStatus, NotificationChannelType } from './types';
|
import { NotificationChannelType } from './types';
|
||||||
import {
|
import {
|
||||||
getChannelsPage,
|
getChannelsPage,
|
||||||
getChannelTypes,
|
getChannelTypes,
|
||||||
@ -68,7 +87,7 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
const [query, setQuery] = useState<NotificationChannelQuery>({
|
const [query, setQuery] = useState<NotificationChannelQuery>({
|
||||||
name: '',
|
name: '',
|
||||||
channelType: undefined,
|
channelType: undefined,
|
||||||
status: undefined,
|
enabled: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 对话框状态
|
// 对话框状态
|
||||||
@ -87,7 +106,7 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
// 加载数据
|
// 加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [pagination.pageNum, pagination.pageSize, query.channelType, query.status]);
|
}, [pagination.pageNum, pagination.pageSize, query.channelType, query.enabled]);
|
||||||
|
|
||||||
const loadChannelTypes = async () => {
|
const loadChannelTypes = async () => {
|
||||||
try {
|
try {
|
||||||
@ -138,7 +157,7 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
setQuery({
|
setQuery({
|
||||||
name: '',
|
name: '',
|
||||||
channelType: undefined,
|
channelType: undefined,
|
||||||
status: undefined,
|
enabled: undefined,
|
||||||
});
|
});
|
||||||
setPagination({
|
setPagination({
|
||||||
pageNum: DEFAULT_CURRENT,
|
pageNum: DEFAULT_CURRENT,
|
||||||
@ -235,30 +254,73 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 状态标签
|
// 状态标签
|
||||||
const getStatusBadge = (status?: NotificationChannelStatus) => {
|
const getStatusBadge = (enabled: boolean) => {
|
||||||
if (status === NotificationChannelStatus.ENABLED) {
|
if (enabled) {
|
||||||
return <Badge variant="default">启用</Badge>;
|
return <Badge variant="default">启用</Badge>;
|
||||||
}
|
}
|
||||||
return <Badge variant="secondary">禁用</Badge>;
|
return <Badge variant="secondary">禁用</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<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>
|
||||||
|
|
||||||
|
{/* 渠道管理 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">消息中心</h1>
|
<div>
|
||||||
|
<CardTitle>通知渠道</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
管理通知渠道配置,支持企业微信、邮件等多种渠道
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
<Button onClick={handleCreate}>
|
<Button onClick={handleCreate}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
新建渠道
|
新建渠道
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
{/* 搜索筛选 */}
|
<CardContent>
|
||||||
<div className="flex items-end gap-4">
|
{/* 搜索区域 */}
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<label className="text-sm font-medium">渠道名称</label>
|
<div className="flex-1">
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索渠道名称"
|
placeholder="搜索渠道名称"
|
||||||
value={query.name}
|
value={query.name}
|
||||||
@ -268,13 +330,8 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
handleSearch();
|
handleSearch();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="pl-10"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-48 space-y-2">
|
|
||||||
<label className="text-sm font-medium">渠道类型</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={query.channelType || 'all'}
|
value={query.channelType || 'all'}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@ -285,52 +342,24 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-48">
|
||||||
<SelectValue placeholder="全部类型" />
|
<SelectValue placeholder="选择渠道类型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部类型</SelectItem>
|
<SelectItem value="all">全部类型</SelectItem>
|
||||||
{channelTypes.map((type) => (
|
{channelTypes
|
||||||
|
.filter((type) => type.code && type.code.trim() !== '')
|
||||||
|
.map((type) => (
|
||||||
<SelectItem key={type.code} value={type.code}>
|
<SelectItem key={type.code} value={type.code}>
|
||||||
{type.label}
|
{type.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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}>
|
<Button onClick={handleSearch}>
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="h-4 w-4 mr-2" />
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="outline" onClick={handleReset}>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 表格 */}
|
{/* 表格 */}
|
||||||
@ -338,41 +367,45 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-16">ID</TableHead>
|
|
||||||
<TableHead>渠道名称</TableHead>
|
<TableHead>渠道名称</TableHead>
|
||||||
<TableHead className="w-32">渠道类型</TableHead>
|
<TableHead>渠道类型</TableHead>
|
||||||
<TableHead className="w-24">状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>描述</TableHead>
|
<TableHead>描述</TableHead>
|
||||||
<TableHead className="w-44">创建时间</TableHead>
|
<TableHead>创建时间</TableHead>
|
||||||
<TableHead className="w-64">操作</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="h-32 text-center">
|
<TableCell colSpan={6} className="h-32 text-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : channels.length === 0 ? (
|
) : channels.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
暂无数据
|
暂无数据
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
channels.map((channel) => (
|
channels.map((channel) => (
|
||||||
<TableRow key={channel.id}>
|
<TableRow key={channel.id}>
|
||||||
<TableCell>{channel.id}</TableCell>
|
|
||||||
<TableCell className="font-medium">{channel.name}</TableCell>
|
<TableCell className="font-medium">{channel.name}</TableCell>
|
||||||
<TableCell>{getChannelTypeBadge(channel.channelType)}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{getStatusBadge(channel.status)}</TableCell>
|
{getChannelTypeBadge(channel.channelType)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={channel.enabled ? "default" : "secondary"}>
|
||||||
|
{channel.enabled ? "启用" : "禁用"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="max-w-xs truncate">
|
<TableCell className="max-w-xs truncate">
|
||||||
{channel.description || '-'}
|
{channel.description || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{channel.createTime}</TableCell>
|
<TableCell>{channel.createTime}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -386,7 +419,7 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
<TestTube className="h-4 w-4" />
|
<TestTube className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{channel.status === NotificationChannelStatus.ENABLED ? (
|
{channel.enabled ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -434,16 +467,17 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
{!loading && channels.length > 0 && (
|
<div className="mt-4">
|
||||||
<div className="flex justify-end border-t border-border bg-muted/40">
|
|
||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
pageIndex={pagination.pageNum - 1}
|
currentPage={pagination.pageNum}
|
||||||
|
totalPages={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
||||||
|
totalItems={pagination.totalElements}
|
||||||
pageSize={pagination.pageSize}
|
pageSize={pagination.pageSize}
|
||||||
pageCount={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 创建/编辑对话框 */}
|
{/* 创建/编辑对话框 */}
|
||||||
<NotificationChannelDialog
|
<NotificationChannelDialog
|
||||||
@ -471,7 +505,7 @@ const NotificationChannelList: React.FC = () => {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
confirmText="删除"
|
confirmText="删除"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,13 +6,7 @@ export enum NotificationChannelType {
|
|||||||
EMAIL = 'EMAIL' // 邮件
|
EMAIL = 'EMAIL' // 邮件
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 通知渠道状态现在使用布尔值 enabled 字段
|
||||||
* 通知渠道状态枚举
|
|
||||||
*/
|
|
||||||
export enum NotificationChannelStatus {
|
|
||||||
ENABLED = 'ENABLED', // 启用
|
|
||||||
DISABLED = 'DISABLED' // 禁用
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 2. 配置基类(不直接使用,用于类型推断)
|
// 2. 配置基类(不直接使用,用于类型推断)
|
||||||
@ -83,8 +77,8 @@ export interface NotificationChannelDTO {
|
|||||||
*/
|
*/
|
||||||
config: WeworkNotificationConfigDTO | EmailNotificationConfigDTO;
|
config: WeworkNotificationConfigDTO | EmailNotificationConfigDTO;
|
||||||
|
|
||||||
/** 状态 */
|
/** 是否启用 */
|
||||||
status?: NotificationChannelStatus;
|
enabled: boolean;
|
||||||
|
|
||||||
/** 描述 */
|
/** 描述 */
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -150,7 +144,7 @@ export interface ChannelTypeOption {
|
|||||||
export interface NotificationChannelQuery {
|
export interface NotificationChannelQuery {
|
||||||
name?: string; // 渠道名称(模糊查询)
|
name?: string; // 渠道名称(模糊查询)
|
||||||
channelType?: NotificationChannelType; // 渠道类型
|
channelType?: NotificationChannelType; // 渠道类型
|
||||||
status?: NotificationChannelStatus; // 状态
|
enabled?: boolean; // 是否启用
|
||||||
|
|
||||||
// 分页参数
|
// 分页参数
|
||||||
page?: number;
|
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