重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-28 17:51:29 +08:00
parent 0a10f1def1
commit 69fc7fe163
2 changed files with 245 additions and 293 deletions

View File

@ -197,19 +197,21 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
</div>
)}
{/* 最近部署记录 */}
{/* 最近部署记录 - 固定显示2条槽位 */}
<div className="mt-2 space-y-2">
<div className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground mb-1">
<History className="h-3 w-3" />
<span></span>
</div>
{app.recentDeployRecords && app.recentDeployRecords.length > 0 ? (
<>
{/* 显示实际记录最多2条 */}
{app.recentDeployRecords.slice(0, 2).map((record) => {
{/* 始终显示2条记录槽位确保卡片高度一致 */}
{Array.from({ length: 2 }).map((_, index) => {
const record = app.recentDeployRecords?.[index];
if (record) {
// 显示实际记录
const { icon: StatusIcon, color } = getStatusIcon(record.status);
return (
<div key={record.id} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1">
<div key={record.id} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1 h-[60px]">
{/* 第一行:状态、编号、部署人 */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1">
@ -267,34 +269,24 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
)}
</div>
);
} else {
// 显示骨架屏占位 - 3条线模拟实际记录的3行
return (
<div key={`skeleton-${index}`} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1 h-[60px]">
{/* 第一行:状态、编号、部署人 */}
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3.5 w-12 ml-auto" />
</div>
{/* 第二行:时间信息 */}
<Skeleton className="h-3 w-32" />
{/* 第三行:备注 */}
<Skeleton className="h-3 w-24" />
</div>
);
}
})}
{/* 如果记录少于2条用骨架屏补充 */}
{app.recentDeployRecords.length < 2 && (
Array.from({ length: 2 - app.recentDeployRecords.length }).map((_, index) => (
<div key={`skeleton-${index}`} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1">
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3.5 w-12 ml-auto" />
</div>
<Skeleton className="h-3 w-32" />
</div>
))
)}
</>
) : (
/* 如果没有记录显示2条骨架记录 */
Array.from({ length: 2 }).map((_, index) => (
<div key={`skeleton-${index}`} className="p-1.5 rounded-md bg-muted/30 border border-muted/50 space-y-1">
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3.5 w-12 ml-auto" />
</div>
<Skeleton className="h-3 w-32" />
</div>
))
)}
</div>
</div>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useMemo, useRef } from 'react';
import { PageContainer } from '@/components/ui/page-container';
import {
Card,
@ -6,21 +6,11 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useToast } from '@/components/ui/use-toast';
import { DataTablePagination } from '@/components/ui/pagination';
import { PaginatedTable, type ColumnDef, type SearchFieldDef, type PaginatedTableRef } from '@/components/ui/paginated-table';
import { Plus, Edit, Trash2, Link, Server, Activity, Database } from 'lucide-react';
import EditDialog from './components/EditDialog';
import DeleteDialog from './components/DeleteDialog';
@ -31,13 +21,7 @@ const DEFAULT_PAGE_SIZE = 10;
const ExternalPage: React.FC = () => {
const { toast } = useToast();
const [list, setList] = useState<ExternalSystemResponse[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<ExternalSystemQuery>({
pageNum: 0,
pageSize: DEFAULT_PAGE_SIZE,
});
const [total, setTotal] = useState(0);
const tableRef = useRef<PaginatedTableRef<ExternalSystemResponse>>(null);
const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 });
// 对话框状态
@ -45,43 +29,21 @@ const ExternalPage: React.FC = () => {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [currentRecord, setCurrentRecord] = useState<ExternalSystemResponse>();
// 加载数据
const loadData = async () => {
setLoading(true);
try {
// 包装 fetchFn
const fetchData = async (query: ExternalSystemQuery) => {
const response = await service.getExternalSystemsPage(query);
if (response) {
setList(response.content || []);
setTotal(response.totalElements || 0);
// 计算统计数据
const all = response.content || [];
setStats({
total: all.length,
total: response.totalElements || 0,
enabled: all.filter((item) => item.enabled).length,
disabled: all.filter((item) => !item.enabled).length,
});
}
} catch (error) {
toast({ title: '加载失败', description: '无法加载外部系统列表', variant: 'destructive' });
} finally {
setLoading(false);
}
return response;
};
useEffect(() => {
loadData();
}, [query.pageNum, query.pageSize]);
// 搜索
const handleSearch = () => {
setQuery({ ...query, pageNum: 0 });
loadData();
};
// 重置
const handleReset = () => {
setQuery({ pageNum: 0, pageSize: DEFAULT_PAGE_SIZE });
loadData();
const handleSuccess = () => {
tableRef.current?.refresh();
};
// 测试连接
@ -94,7 +56,7 @@ const ExternalPage: React.FC = () => {
variant: success ? 'default' : 'destructive',
});
// 刷新列表以更新最后连接时间等信息
loadData();
tableRef.current?.refresh();
} catch (error) {
toast({ title: '测试失败', description: '无法测试连接', variant: 'destructive' });
}
@ -105,7 +67,7 @@ const ExternalPage: React.FC = () => {
try {
await service.updateStatus(id, enabled);
toast({ title: '更新成功', description: `系统状态已${enabled ? '启用' : '禁用'}` });
loadData();
tableRef.current?.refresh();
} catch (error) {
toast({ title: '更新失败', description: '无法更新系统状态', variant: 'destructive' });
}
@ -131,6 +93,143 @@ const ExternalPage: React.FC = () => {
return authTypeMap[authType];
};
// 搜索字段定义
const searchFields: SearchFieldDef[] = useMemo(() => [
{ key: 'name', type: 'input', placeholder: '系统名称', width: 'w-[200px]' },
{
key: 'type',
type: 'select',
placeholder: '系统类型',
width: 'w-[200px]',
options: [
{ label: 'Jenkins', value: 'JENKINS' },
{ label: 'Git', value: 'GIT' },
{ label: '禅道', value: 'ZENTAO' },
],
},
{
key: 'enabled',
type: 'select',
placeholder: '状态',
width: 'w-[200px]',
options: [
{ label: '已启用', value: 'true' },
{ label: '已禁用', value: 'false' },
],
},
], []);
// 列定义
const columns: ColumnDef<ExternalSystemResponse>[] = useMemo(() => [
{
key: 'id',
title: 'ID',
dataIndex: 'id',
width: '80px',
},
{
key: 'name',
title: '系统名称',
dataIndex: 'name',
width: '150px',
},
{
key: 'type',
title: '系统类型',
width: '100px',
render: (_, item) => {
const typeInfo = getSystemTypeLabel(item.type);
return <Badge variant={typeInfo.variant}>{typeInfo.label}</Badge>;
},
},
{
key: 'url',
title: '系统地址',
width: '250px',
render: (_, item) => (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
onClick={() => {
setTimeout(() => tableRef.current?.refresh(), 100);
}}
>
<Link className="h-3 w-3" />
{item.url}
</a>
),
},
{
key: 'authType',
title: '认证方式',
width: '120px',
render: (_, item) => getAuthTypeLabel(item.authType),
},
{
key: 'lastConnectTime',
title: '最后连接时间',
dataIndex: 'lastConnectTime',
width: '150px',
render: (value) => value || '-',
},
{
key: 'enabled',
title: '状态',
width: '100px',
render: (_, item) => (
<Switch
checked={item.enabled}
onCheckedChange={(checked) => handleStatusChange(item.id, checked)}
/>
),
},
{
key: 'actions',
title: '操作',
width: '250px',
sticky: true,
render: (_, item) => (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => {
setCurrentRecord(item);
setEditDialogOpen(true);
}}
>
<Edit className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => handleTestConnection(item.id)}
>
<Link className="h-3.5 w-3.5 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-destructive hover:text-destructive"
onClick={() => {
setCurrentRecord(item);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
),
},
], []);
return (
<PageContainer>
{/* 页面标题 */}
@ -179,160 +278,21 @@ const ExternalPage: React.FC = () => {
</Card>
</div>
{/* 搜索过滤 */}
<Card>
<div className="p-6">
<div className="flex items-center gap-4">
<Input
placeholder="系统名称"
value={query.name || ''}
onChange={(e) => setQuery({ ...query, name: e.target.value })}
className="max-w-[200px]"
/>
<Select
value={query.type}
onValueChange={(value) => setQuery({ ...query, type: value as SystemType })}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="系统类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="JENKINS">Jenkins</SelectItem>
<SelectItem value="GIT">Git</SelectItem>
<SelectItem value="ZENTAO"></SelectItem>
</SelectContent>
</Select>
<Select
value={query.enabled?.toString()}
onValueChange={(value) => setQuery({ ...query, enabled: value === 'true' })}
>
<SelectTrigger className="max-w-[200px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true"></SelectItem>
<SelectItem value="false"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleReset}>
</Button>
<Button onClick={handleSearch}>
</Button>
</div>
</div>
</Card>
{/* 数据表格 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table minWidth="1120px">
<TableHeader>
<TableRow>
<TableHead width="150px"></TableHead>
<TableHead width="100px"></TableHead>
<TableHead width="250px"></TableHead>
<TableHead width="120px"></TableHead>
<TableHead width="150px"></TableHead>
<TableHead width="100px"></TableHead>
<TableHead width="250px" sticky></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
list.map((item) => (
<TableRow key={item.id}>
<TableCell width="150px" className="font-medium">{item.name}</TableCell>
<TableCell width="100px">
<Badge variant={getSystemTypeLabel(item.type).variant}>
{getSystemTypeLabel(item.type).label}
</Badge>
</TableCell>
<TableCell width="250px">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
onClick={() => {
// 点击链接后刷新列表
setTimeout(() => loadData(), 100);
}}
>
<Link className="h-3 w-3" />
{item.url}
</a>
</TableCell>
<TableCell width="120px">{getAuthTypeLabel(item.authType)}</TableCell>
<TableCell width="150px" className="text-muted-foreground">
{item.lastConnectTime || '-'}
</TableCell>
<TableCell width="100px">
<Switch
checked={item.enabled}
onCheckedChange={(checked) => handleStatusChange(item.id, checked)}
<PaginatedTable<ExternalSystemResponse, ExternalSystemQuery>
ref={tableRef}
fetchFn={fetchData}
columns={columns}
searchFields={searchFields}
rowKey="id"
minWidth="1120px"
pageSize={DEFAULT_PAGE_SIZE}
/>
</TableCell>
<TableCell width="250px" sticky>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentRecord(item);
setEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(item.id)}
>
<Link className="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCurrentRecord(item);
setDeleteDialogOpen(true);
}}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<div className="flex justify-end border-t border-border bg-muted/40">
<DataTablePagination
pageIndex={query.pageNum || 0}
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
pageCount={Math.ceil(total / (query.pageSize || DEFAULT_PAGE_SIZE))}
onPageChange={(page) => setQuery({ ...query, pageNum: page })}
/>
</div>
</div>
</CardContent>
</Card>
@ -341,13 +301,13 @@ const ExternalPage: React.FC = () => {
open={editDialogOpen}
record={currentRecord}
onOpenChange={setEditDialogOpen}
onSuccess={loadData}
onSuccess={handleSuccess}
/>
<DeleteDialog
open={deleteDialogOpen}
record={currentRecord}
onOpenChange={setDeleteDialogOpen}
onSuccess={loadData}
onSuccess={handleSuccess}
/>
</PageContainer>
);