重构消息通知弹窗
This commit is contained in:
parent
7a4f5f37c2
commit
6de57aff67
@ -77,7 +77,16 @@ const EnvironmentList: React.FC = () => {
|
||||
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
|
||||
{ key: 'envCode', title: '环境编码', dataIndex: 'envCode', width: '150px' },
|
||||
{ key: 'envName', title: '环境名称', dataIndex: 'envName', width: '150px' },
|
||||
{ key: 'envDesc', title: '环境描述', dataIndex: 'envDesc', width: '200px' },
|
||||
{
|
||||
key: 'envDesc',
|
||||
title: '环境描述',
|
||||
width: '200px',
|
||||
render: (_, record) => (
|
||||
<div className="truncate max-w-[200px]" title={record.envDesc || ''}>
|
||||
{record.envDesc || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'teamCount', title: '团队数', dataIndex: 'teamCount', width: '100px' },
|
||||
{ key: 'applicationCount', title: '应用数', dataIndex: 'applicationCount', width: '100px' },
|
||||
{
|
||||
|
||||
@ -1,22 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState, useMemo, useRef, 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 { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -24,33 +10,21 @@ import {
|
||||
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';
|
||||
import { DataTablePagination } from '@/components/ui/pagination';
|
||||
import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_NUM } from '@/utils/page';
|
||||
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Loader2,
|
||||
Edit,
|
||||
Trash2,
|
||||
TestTube,
|
||||
RefreshCw,
|
||||
Power,
|
||||
PowerOff,
|
||||
Activity,
|
||||
Database,
|
||||
Server,
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type {
|
||||
NotificationChannel,
|
||||
NotificationChannelQuery,
|
||||
@ -69,32 +43,16 @@ import NotificationChannelDialog from './components/NotificationChannelDialog';
|
||||
|
||||
const NotificationChannelList: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const tableRef = useRef<PaginatedTableRef<NotificationChannel>>(null);
|
||||
|
||||
// 状态管理
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [channelTypes, setChannelTypes] = useState<ChannelTypeOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
|
||||
// 分页
|
||||
const [pagination, setPagination] = useState({
|
||||
pageNum: DEFAULT_PAGE_NUM,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
totalElements: 0,
|
||||
});
|
||||
|
||||
// 查询条件
|
||||
const [query, setQuery] = useState<NotificationChannelQuery>({
|
||||
name: '',
|
||||
channelType: undefined,
|
||||
enabled: undefined,
|
||||
});
|
||||
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
|
||||
|
||||
// 对话框状态
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<number | undefined>();
|
||||
|
||||
// 删除确认对话框
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
@ -103,11 +61,6 @@ const NotificationChannelList: React.FC = () => {
|
||||
loadChannelTypes();
|
||||
}, []);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [pagination.pageNum, pagination.pageSize, query.channelType, query.enabled]);
|
||||
|
||||
const loadChannelTypes = async () => {
|
||||
try {
|
||||
const types = await getChannelTypes();
|
||||
@ -121,69 +74,39 @@ const NotificationChannelList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getChannelsPage({
|
||||
...query,
|
||||
page: pagination.pageNum,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
|
||||
setChannels(res.content || []);
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
totalElements: res.totalElements || 0,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '加载失败',
|
||||
description: error.response?.data?.message || error.message,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
setPagination((prev) => ({ ...prev, pageNum: DEFAULT_PAGE_NUM }));
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
setQuery({
|
||||
name: '',
|
||||
channelType: undefined,
|
||||
enabled: undefined,
|
||||
// 包装 fetchFn,同时更新统计数据
|
||||
const fetchData = async (query: any) => {
|
||||
const result = await getChannelsPage({
|
||||
...query,
|
||||
page: query.pageNum,
|
||||
size: query.pageSize,
|
||||
});
|
||||
setPagination({
|
||||
pageNum: DEFAULT_PAGE_NUM,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
totalElements: 0,
|
||||
// 更新统计
|
||||
const all = result.content || [];
|
||||
setStats({
|
||||
total: result.totalElements || 0,
|
||||
enabled: all.filter((item) => item.enabled).length,
|
||||
disabled: all.filter((item) => !item.enabled).length,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// 分页切换
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPagination({ ...pagination, pageNum: newPage });
|
||||
};
|
||||
|
||||
// 创建
|
||||
const handleCreate = () => {
|
||||
const handleAdd = () => {
|
||||
setEditId(undefined);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (id: number) => {
|
||||
setEditId(id);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleSuccess = () => {
|
||||
setDialogOpen(false);
|
||||
setEditId(undefined);
|
||||
tableRef.current?.refresh();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
await deleteChannel(deleteId);
|
||||
@ -199,7 +122,7 @@ const NotificationChannelList: React.FC = () => {
|
||||
await disableChannel(id);
|
||||
toast({ title: '已禁用' });
|
||||
}
|
||||
loadData();
|
||||
tableRef.current?.refresh();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@ -253,25 +176,134 @@ const NotificationChannelList: React.FC = () => {
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
// 状态标签
|
||||
const getStatusBadge = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
return <Badge variant="default">启用</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">禁用</Badge>;
|
||||
};
|
||||
// 搜索字段定义
|
||||
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||
{ key: 'name', type: 'input', placeholder: '搜索渠道名称', width: 'w-[200px]' },
|
||||
{
|
||||
key: 'channelType',
|
||||
type: 'select',
|
||||
placeholder: '渠道类型',
|
||||
width: 'w-[150px]',
|
||||
options: channelTypes
|
||||
.filter((type) => type.code && type.code.trim() !== '')
|
||||
.map((type) => ({ label: type.label, value: type.code })),
|
||||
},
|
||||
], [channelTypes]);
|
||||
|
||||
// 列定义
|
||||
const columns: ColumnDef<NotificationChannel>[] = useMemo(() => [
|
||||
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
|
||||
{ key: 'name', title: '渠道名称', dataIndex: 'name', width: '150px' },
|
||||
{
|
||||
key: 'channelType',
|
||||
title: '渠道类型',
|
||||
width: '120px',
|
||||
render: (_, record) => getChannelTypeBadge(record.channelType),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
title: '状态',
|
||||
width: '100px',
|
||||
render: (_, record) => (
|
||||
<Badge variant={record.enabled ? 'default' : 'secondary'}>
|
||||
{record.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
title: '描述',
|
||||
width: '200px',
|
||||
render: (_, record) => (
|
||||
<div className="truncate max-w-[200px]" title={record.description || ''}>
|
||||
{record.description || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'createTime', title: '创建时间', dataIndex: 'createTime', width: '180px' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '240px',
|
||||
sticky: true,
|
||||
render: (_, record) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleTest(record.id!)}
|
||||
disabled={testing === record.id}
|
||||
title="测试连接"
|
||||
>
|
||||
{testing === record.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{record.enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleToggleStatus(record.id!, false)}
|
||||
title="禁用"
|
||||
>
|
||||
<PowerOff className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleToggleStatus(record.id!, true)}
|
||||
title="启用"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(record.id!)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDeleteId(record.id!);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [testing, channelTypes]);
|
||||
|
||||
// 工具栏
|
||||
const toolbar = (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建渠道
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<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>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
<p className="text-xs text-muted-foreground">所有渠道</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -281,9 +313,7 @@ const NotificationChannelList: React.FC = () => {
|
||||
<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>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
|
||||
<p className="text-xs text-muted-foreground">正在使用中</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -293,9 +323,7 @@ const NotificationChannelList: React.FC = () => {
|
||||
<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>
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
|
||||
<p className="text-xs text-muted-foreground">暂时停用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -304,178 +332,24 @@ const NotificationChannelList: React.FC = () => {
|
||||
{/* 渠道管理 */}
|
||||
<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>
|
||||
<CardTitle>通知渠道</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
管理通知渠道配置,支持企业微信、邮件等多种渠道
|
||||
</CardDescription>
|
||||
</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>
|
||||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<PaginatedTable<NotificationChannel, any>
|
||||
ref={tableRef}
|
||||
fetchFn={fetchData}
|
||||
columns={columns}
|
||||
searchFields={searchFields}
|
||||
toolbar={toolbar}
|
||||
rowKey="id"
|
||||
minWidth="1200px"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -485,7 +359,7 @@ const NotificationChannelList: React.FC = () => {
|
||||
editId={editId}
|
||||
channelTypes={channelTypes}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSuccess={loadData}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
@ -499,7 +373,7 @@ const NotificationChannelList: React.FC = () => {
|
||||
onSuccess={() => {
|
||||
toast({ title: '删除成功' });
|
||||
setDeleteId(null);
|
||||
loadData();
|
||||
tableRef.current?.refresh();
|
||||
}}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
variant="destructive"
|
||||
|
||||
@ -1,25 +1,11 @@
|
||||
/**
|
||||
* 通知模板管理页面
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useRef } 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 { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -27,15 +13,11 @@ import {
|
||||
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_PAGE_NUM } from '@/utils/page';
|
||||
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Loader2,
|
||||
Edit,
|
||||
Trash2,
|
||||
TestTube,
|
||||
@ -44,15 +26,8 @@ import {
|
||||
Activity,
|
||||
Database,
|
||||
Server,
|
||||
MoreHorizontal
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { NotificationChannelType } from '../../NotificationChannel/List/types';
|
||||
import type {
|
||||
NotificationTemplateDTO,
|
||||
@ -83,41 +58,19 @@ const CHANNEL_TYPE_OPTIONS: ChannelTypeOption[] = [
|
||||
|
||||
const NotificationTemplateList: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const tableRef = useRef<PaginatedTableRef<NotificationTemplateDTO>>(null);
|
||||
|
||||
// 状态管理
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [list, setList] = useState<NotificationTemplateDTO[]>([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageNum: DEFAULT_PAGE_NUM,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
totalElements: 0,
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
enabled: 0,
|
||||
disabled: 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 [testModalVisible, setTestModalVisible] = useState(false);
|
||||
const [testTemplate, setTestTemplate] = useState<NotificationTemplateDTO | undefined>();
|
||||
|
||||
// 确认对话框
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
@ -130,73 +83,35 @@ const NotificationTemplateList: React.FC = () => {
|
||||
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,
|
||||
// 包装 fetchFn,同时更新统计数据
|
||||
const fetchData = async (query: NotificationTemplateQuery) => {
|
||||
const result = await getTemplateList(query);
|
||||
// 更新统计
|
||||
const all = result.content || [];
|
||||
setStats({
|
||||
total: result.totalElements || 0,
|
||||
enabled: all.filter((item) => item.enabled).length,
|
||||
disabled: all.filter((item) => !item.enabled).length,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [pagination.pageNum, pagination.pageSize]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
setPagination(prev => ({ ...prev, pageNum: DEFAULT_PAGE_NUM }));
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 新建模板
|
||||
const handleAdd = () => {
|
||||
setCurrentTemplate(undefined);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 编辑模板
|
||||
const handleEdit = (template: NotificationTemplateDTO) => {
|
||||
setCurrentTemplate(template);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setModalVisible(false);
|
||||
setCurrentTemplate(undefined);
|
||||
tableRef.current?.refresh();
|
||||
};
|
||||
|
||||
// 删除模板
|
||||
const handleDelete = (template: NotificationTemplateDTO) => {
|
||||
setConfirmDialog({
|
||||
@ -207,7 +122,7 @@ const NotificationTemplateList: React.FC = () => {
|
||||
try {
|
||||
await deleteTemplate(template.id);
|
||||
toast({ title: '删除成功' });
|
||||
loadData();
|
||||
tableRef.current?.refresh();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@ -237,7 +152,7 @@ const NotificationTemplateList: React.FC = () => {
|
||||
await enableTemplate(template.id);
|
||||
toast({ title: '已启用' });
|
||||
}
|
||||
loadData();
|
||||
tableRef.current?.refresh();
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@ -255,10 +170,136 @@ const NotificationTemplateList: React.FC = () => {
|
||||
return option?.label || type;
|
||||
};
|
||||
|
||||
// 搜索字段定义
|
||||
const searchFields: SearchFieldDef[] = useMemo(() => [
|
||||
{ key: 'name', type: 'input', placeholder: '搜索模板名称或编码', width: 'w-[220px]' },
|
||||
{
|
||||
key: 'channelType',
|
||||
type: 'select',
|
||||
placeholder: '渠道类型',
|
||||
width: 'w-[150px]',
|
||||
options: CHANNEL_TYPE_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.code,
|
||||
})),
|
||||
},
|
||||
], []);
|
||||
|
||||
// 列定义
|
||||
const columns: ColumnDef<NotificationTemplateDTO>[] = useMemo(() => [
|
||||
{ key: 'id', title: 'ID', dataIndex: 'id', width: '80px' },
|
||||
{ key: 'name', title: '模板名称', dataIndex: 'name', width: '150px' },
|
||||
{ key: 'code', title: '模板编码', dataIndex: 'code', width: '150px' },
|
||||
{
|
||||
key: 'titleTemplate',
|
||||
title: '标题模板',
|
||||
width: '200px',
|
||||
render: (_, record) => (
|
||||
<div className="truncate max-w-[200px]" title={record.titleTemplate || ''}>
|
||||
{record.titleTemplate || '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'channelType',
|
||||
title: '渠道类型',
|
||||
width: '120px',
|
||||
render: (_, record) => (
|
||||
<Badge variant="outline">{getChannelTypeLabel(record.channelType)}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
title: '状态',
|
||||
width: '100px',
|
||||
render: (_, record) => (
|
||||
<Badge variant={record.enabled ? 'default' : 'secondary'}>
|
||||
{record.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'createTime', title: '创建时间', dataIndex: 'createTime', width: '180px' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '240px',
|
||||
sticky: true,
|
||||
render: (_, record) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleTestTemplate(record)}
|
||||
disabled={!record.enabled}
|
||||
title="测试模板"
|
||||
>
|
||||
<TestTube className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(record)}
|
||||
disabled={operationLoading.toggling === record.id}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{record.enabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleToggleStatus(record)}
|
||||
disabled={operationLoading.toggling === record.id}
|
||||
title="禁用"
|
||||
>
|
||||
{operationLoading.toggling === record.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PowerOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleToggleStatus(record)}
|
||||
disabled={operationLoading.toggling === record.id}
|
||||
title="启用"
|
||||
>
|
||||
{operationLoading.toggling === record.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Power className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(record)}
|
||||
disabled={operationLoading.toggling === record.id}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [operationLoading.toggling]);
|
||||
|
||||
// 工具栏
|
||||
const toolbar = (
|
||||
<Button onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建模板
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">总模板数</CardTitle>
|
||||
@ -294,184 +335,24 @@ const NotificationTemplateList: React.FC = () => {
|
||||
{/* 模板管理 */}
|
||||
<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>
|
||||
<CardTitle>通知模板</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
管理通知消息模板,支持 FreeMarker 语法
|
||||
</CardDescription>
|
||||
</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>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</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>
|
||||
) : list.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} 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 className="max-w-xs truncate" title={template.titleTemplate}>
|
||||
{template.titleTemplate || '-'}
|
||||
</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={() => handleTestTemplate(template)}
|
||||
disabled={!template.enabled}
|
||||
title="测试模板"
|
||||
>
|
||||
<TestTube className="h-4 w-4" />
|
||||
</Button>
|
||||
<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>
|
||||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<PaginatedTable<NotificationTemplateDTO, NotificationTemplateQuery>
|
||||
ref={tableRef}
|
||||
fetchFn={fetchData}
|
||||
columns={columns}
|
||||
searchFields={searchFields}
|
||||
toolbar={toolbar}
|
||||
rowKey="id"
|
||||
minWidth="1300px"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -481,7 +362,7 @@ const NotificationTemplateList: React.FC = () => {
|
||||
editId={currentTemplate?.id}
|
||||
channelTypes={CHANNEL_TYPE_OPTIONS}
|
||||
onOpenChange={setModalVisible}
|
||||
onSuccess={loadData}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* 测试模板对话框 */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user