增加团队管理页面

This commit is contained in:
dengqichen 2025-10-29 17:55:34 +08:00
parent 98cef3f68f
commit 945d827eb8
7 changed files with 1472 additions and 722 deletions

View File

@ -477,6 +477,12 @@ const GitManager: React.FC = () => {
{group.name}
</span>
{group.projectCount !== undefined && group.projectCount > 0 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
{group.projectCount}
</Badge>
)}
{group.webUrl && (
<Tooltip>
<TooltipTrigger asChild>
@ -739,6 +745,13 @@ const GitManager: React.FC = () => {
</span>
)}
{project.branchCount !== undefined && project.branchCount > 0 && (
<span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{project.branchCount}
</span>
)}
{project.lastActivityAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />

View File

@ -1,20 +1,26 @@
import React, { useState, useEffect, useMemo } from 'react';
import { PageContainer } from '@/components/ui/page-container';
import { RefreshCw, Layers, Box, Activity, Search, CheckCircle, XCircle, AlertTriangle, Loader2, ExternalLink } from 'lucide-react';
import {
RefreshCw,
Layers,
Box,
Activity,
Search,
CheckCircle,
XCircle,
AlertTriangle,
Loader2,
ExternalLink,
GitBranch,
Calendar
} from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@ -27,25 +33,44 @@ import {
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { Progress } from '@/components/ui/progress';
import type { JenkinsInstance, JenkinsInstanceDTO } from './types';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { JenkinsInstance, JenkinsInstanceDTO, JenkinsViewDTO, JenkinsJobDTO } from './types';
import { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
const JenkinsManagerList: React.FC = () => {
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]);
const [currentJenkinsId, setCurrentJenkinsId] = useState<string>();
const [selectedInstanceId, setSelectedInstanceId] = useState<number>();
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>();
const [loading, setLoading] = useState(false);
const [currentView, setCurrentView] = useState<number>();
const [syncing, setSyncing] = useState<Record<string, boolean>>({
// 三栏选中状态
const [selectedView, setSelectedView] = useState<JenkinsViewDTO | null>(null);
const [selectedJob, setSelectedJob] = useState<JenkinsJobDTO | null>(null);
// 加载状态
const [loading, setLoading] = useState({
views: false,
jobs: false,
builds: false,
all: false,
});
// 搜索和过滤状态
const [searchKeyword, setSearchKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
// 同步状态
const [syncing, setSyncing] = useState({
all: false,
views: false,
jobs: false,
builds: false,
});
// 搜索状态
const [viewSearch, setViewSearch] = useState('');
const [jobSearch, setJobSearch] = useState('');
const [buildSearch, setBuildSearch] = useState('');
const { toast } = useToast();
@ -54,8 +79,8 @@ const JenkinsManagerList: React.FC = () => {
try {
const data = await getJenkinsInstances();
setJenkinsList(data);
if (!currentJenkinsId && data.length > 0) {
setCurrentJenkinsId(String(data[0].id));
if (!selectedInstanceId && data.length > 0) {
setSelectedInstanceId(data[0].id);
}
} catch (error) {
toast({
@ -68,14 +93,15 @@ const JenkinsManagerList: React.FC = () => {
// 获取 Jenkins 实例详情
const loadInstanceDetails = async () => {
if (!currentJenkinsId) return;
setLoading(true);
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, views: true }));
try {
const data = await getJenkinsInstance(currentJenkinsId);
const data = await getJenkinsInstance(String(selectedInstanceId));
setInstanceDetails(data);
// 设置默认视图
if (data.jenkinsViewList.length > 0 && !currentView) {
setCurrentView(data.jenkinsViewList[0].id);
// 自动选择第一个 View
if (data.jenkinsViewList.length > 0 && !selectedView) {
setSelectedView(data.jenkinsViewList[0]);
}
} catch (error) {
toast({
@ -84,38 +110,42 @@ const JenkinsManagerList: React.FC = () => {
duration: 3000,
});
} finally {
setLoading(false);
setLoading((prev) => ({ ...prev, views: false }));
}
};
// 切换 Jenkins 实例
const handleJenkinsChange = (id: string) => {
setCurrentJenkinsId(id);
const handleInstanceChange = (id: string) => {
setSelectedInstanceId(Number(id));
setInstanceDetails(undefined);
setCurrentView(undefined);
setSearchKeyword('');
setStatusFilter('all');
setSelectedView(null);
setSelectedJob(null);
setViewSearch('');
setJobSearch('');
setBuildSearch('');
};
// 单个同步
const handleSync = async (type: 'views' | 'jobs' | 'builds') => {
if (!currentJenkinsId) return;
setSyncing((prev) => ({ ...prev, [type]: true }));
// 选择 View
const handleSelectView = (view: JenkinsViewDTO) => {
setSelectedView(view);
setSelectedJob(null);
};
// 选择 Job
const handleSelectJob = (job: JenkinsJobDTO) => {
setSelectedJob(job);
// TODO: 加载 Builds 列表
};
// 同步 Views
const handleSyncViews = async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, views: true }));
try {
switch (type) {
case 'views':
await syncViews(currentJenkinsId);
break;
case 'jobs':
await syncJobs(currentJenkinsId);
break;
case 'builds':
await syncBuilds(currentJenkinsId);
break;
}
await loadInstanceDetails();
await syncViews(String(selectedInstanceId));
toast({
title: '同步成功',
description: 'Views 同步任务已启动',
duration: 2000,
});
} catch (error) {
@ -125,23 +155,67 @@ const JenkinsManagerList: React.FC = () => {
duration: 3000,
});
} finally {
setSyncing((prev) => ({ ...prev, [type]: false }));
setSyncing((prev) => ({ ...prev, views: false }));
}
};
// 同步 Jobs
const handleSyncJobs = async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, jobs: true }));
try {
await syncJobs(String(selectedInstanceId));
toast({
title: '同步成功',
description: 'Jobs 同步任务已启动',
duration: 2000,
});
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
duration: 3000,
});
} finally {
setSyncing((prev) => ({ ...prev, jobs: false }));
}
};
// 同步 Builds
const handleSyncBuilds = async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, builds: true }));
try {
await syncBuilds(String(selectedInstanceId));
toast({
title: '同步成功',
description: 'Builds 同步任务已启动',
duration: 2000,
});
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
duration: 3000,
});
} finally {
setSyncing((prev) => ({ ...prev, builds: false }));
}
};
// 全部同步
const handleSyncAll = async () => {
if (!currentJenkinsId) return;
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, all: true }));
try {
await Promise.all([
syncViews(currentJenkinsId),
syncJobs(currentJenkinsId),
syncBuilds(currentJenkinsId),
syncViews(String(selectedInstanceId)),
syncJobs(String(selectedInstanceId)),
syncBuilds(String(selectedInstanceId)),
]);
await loadInstanceDetails();
toast({
title: '全部同步成功',
title: '全部同步任务已启动',
description: '请稍后刷新页面查看同步结果',
duration: 2000,
});
} catch (error) {
@ -156,26 +230,25 @@ const JenkinsManagerList: React.FC = () => {
}
};
useEffect(() => {
loadJenkinsList();
}, []);
useEffect(() => {
if (currentJenkinsId) {
loadInstanceDetails();
// 格式化时间
const formatTime = (time?: string | null) => {
if (!time) return '-';
const diff = dayjs().diff(dayjs(time), 'day');
if (diff > 7) {
return dayjs(time).format('YYYY-MM-DD HH:mm');
}
}, [currentJenkinsId]);
const formatTime = (time: string | null | undefined) => {
if (!time) return 'Never';
return time;
return dayjs(time).fromNow();
};
// 获取构建状态信息
const getBuildStatusInfo = (status: string) => {
const statusMap: Record<string, { variant: 'default' | 'destructive' | 'secondary' | 'outline', icon: React.ReactNode, label: string }> = {
const statusMap: Record<string, {
variant: 'default' | 'destructive' | 'secondary' | 'outline' | 'success';
icon: React.ReactNode;
label: string;
}> = {
SUCCESS: {
variant: 'default',
variant: 'success',
icon: <CheckCircle className="h-3 w-3" />,
label: '成功',
},
@ -216,39 +289,47 @@ const JenkinsManagerList: React.FC = () => {
return 'bg-red-500';
};
// 过滤和搜索 Jobs
const filteredJobs = useMemo(() => {
// 过滤 Views
const filteredViews = useMemo(() => {
if (!instanceDetails) return [];
if (!viewSearch) return instanceDetails.jenkinsViewList;
return instanceDetails.jenkinsViewList.filter((view) =>
view.viewName.toLowerCase().includes(viewSearch.toLowerCase()) ||
(view.description && view.description.toLowerCase().includes(viewSearch.toLowerCase()))
);
}, [instanceDetails, viewSearch]);
let jobs = instanceDetails.jenkinsJobList;
// 按 View 过滤
if (currentView) {
jobs = jobs.filter((job) => job.viewId === currentView);
}
// 按搜索关键词过滤
if (searchKeyword) {
// 过滤 Jobs基于选中的 View
const filteredJobs = useMemo(() => {
if (!instanceDetails || !selectedView) return [];
let jobs = instanceDetails.jenkinsJobList.filter(
(job) => job.viewId === selectedView.id
);
if (jobSearch) {
jobs = jobs.filter((job) =>
job.jobName.toLowerCase().includes(searchKeyword.toLowerCase()) ||
job.description?.toLowerCase().includes(searchKeyword.toLowerCase())
job.jobName.toLowerCase().includes(jobSearch.toLowerCase()) ||
(job.description && job.description.toLowerCase().includes(jobSearch.toLowerCase()))
);
}
// 按状态过滤
if (statusFilter !== 'all') {
jobs = jobs.filter((job) => job.lastBuildStatus === statusFilter);
}
return jobs;
}, [instanceDetails, currentView, searchKeyword, statusFilter]);
}, [instanceDetails, selectedView, jobSearch]);
useEffect(() => {
loadJenkinsList();
}, []);
useEffect(() => {
if (selectedInstanceId) {
loadInstanceDetails();
}
}, [selectedInstanceId]);
// 空状态:没有实例
if (jenkinsList.length === 0) {
return (
<PageContainer>
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
@ -257,7 +338,7 @@ const JenkinsManagerList: React.FC = () => {
<p className="text-sm text-muted-foreground mb-4">
Jenkins
</p>
<Button onClick={() => window.location.href = '/deploy/external'}>
<Button onClick={() => (window.location.href = '/deploy/external')}>
</Button>
</CardContent>
@ -267,12 +348,16 @@ const JenkinsManagerList: React.FC = () => {
}
return (
<PageContainer>
{/* 页面标题 */}
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
<TooltipProvider>
<PageContainer className="h-full flex flex-col">
{/* 页面标题和实例选择 */}
<div className="flex items-center justify-between flex-shrink-0">
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
<div className="flex items-center gap-4">
<Select value={currentJenkinsId} onValueChange={handleJenkinsChange}>
<Select
value={selectedInstanceId?.toString()}
onValueChange={handleInstanceChange}
>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="选择 Jenkins 实例" />
</SelectTrigger>
@ -284,11 +369,7 @@ const JenkinsManagerList: React.FC = () => {
))}
</SelectContent>
</Select>
<Button
onClick={handleSyncAll}
disabled={syncing.all}
variant="outline"
>
<Button onClick={handleSyncAll} disabled={syncing.all} variant="outline">
{syncing.all ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
@ -299,261 +380,286 @@ const JenkinsManagerList: React.FC = () => {
</div>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="min-h-[140px]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Views</CardTitle>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
{/* 三栏布局 */}
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden">
{/* 左栏Views 列表 */}
<Card className="col-span-3 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
Views {instanceDetails && `(${filteredViews.length})`}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleSync('views')}
disabled={syncing.views}
className="h-7 w-7"
onClick={handleSyncViews}
disabled={syncing.views || !selectedInstanceId}
>
<RefreshCw className={`h-3 w-3 ${syncing.views ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-4 w-4 ${syncing.views ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{instanceDetails?.totalViews || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceDetails?.lastSyncViewsTime)}
</p>
</CardContent>
</Card>
<Card className="min-h-[140px]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jobs</CardTitle>
<div className="flex items-center gap-2">
<Box className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleSync('jobs')}
disabled={syncing.jobs}
>
<RefreshCw className={`h-3 w-3 ${syncing.jobs ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{instanceDetails?.totalJobs || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceDetails?.lastSyncJobsTime)}
</p>
</CardContent>
</Card>
<Card className="min-h-[140px]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Builds</CardTitle>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleSync('builds')}
disabled={syncing.builds}
>
<RefreshCw className={`h-3 w-3 ${syncing.builds ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{instanceDetails?.totalBuilds || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceDetails?.lastSyncBuildsTime)}
</p>
</CardContent>
</Card>
</div>
{/* View 切换 */}
{instanceDetails && instanceDetails.jenkinsViewList.length > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex gap-2 overflow-x-auto pb-2">
{instanceDetails.jenkinsViewList.map((view) => (
<Button
key={view.id}
variant={currentView === view.id ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentView(view.id)}
className="whitespace-nowrap"
>
{view.viewName}
</Button>
))}
</div>
</CardContent>
</Card>
)}
{/* 搜索和过滤 */}
{instanceDetails && instanceDetails.jenkinsViewList.length > 0 && (
<Card>
<div className="p-6">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索 Job 名称或描述..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="pl-9"
placeholder="搜索视图..."
value={viewSearch}
onChange={(e) => setViewSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="构建状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="SUCCESS"></SelectItem>
<SelectItem value="FAILURE"></SelectItem>
<SelectItem value="UNSTABLE"></SelectItem>
<SelectItem value="RUNNING"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setSearchKeyword('');
setStatusFilter('all');
}}
</CardHeader>
<div className="flex-1 overflow-auto">
{loading.views ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredViews.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Layers className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : (
<div className="p-2 space-y-2">
{filteredViews.map((view) => (
<div
key={view.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedView?.id === view.id
? 'bg-accent border-primary'
: ''
}`}
onClick={() => handleSelectView(view)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Layers className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="font-medium truncate">
{view.viewName}
</span>
</div>
{view.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{view.description}
</p>
)}
</div>
{view.viewUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={view.viewUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p> Jenkins </p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
))}
</div>
)}
</div>
</Card>
{/* 中栏Jobs 列表 */}
<Card className="col-span-5 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
Jobs {selectedView && `(${filteredJobs.length})`}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncJobs}
disabled={syncing.jobs || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${syncing.jobs ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索任务..."
value={jobSearch}
onChange={(e) => setJobSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
</Card>
)}
{/* Jobs 表格 */}
{loading ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : instanceDetails && instanceDetails.jenkinsViewList.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>
{instanceDetails.jenkinsViewList.find((v) => v.id === currentView)?.viewName || 'Jobs'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table minWidth="1170px">
<TableHeader>
<TableRow>
<TableHead width="250px">Job </TableHead>
<TableHead width="300px"></TableHead>
<TableHead width="100px"></TableHead>
<TableHead width="120px"></TableHead>
<TableHead width="120px"></TableHead>
<TableHead width="180px"></TableHead>
<TableHead width="100px" sticky></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center justify-center text-muted-foreground">
<Box className="h-8 w-8 mb-2" />
<p></p>
<div className="flex-1 overflow-auto">
{loading.jobs ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : !selectedView ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Box className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : filteredJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Box className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
</TableCell>
</TableRow>
) : (
filteredJobs.map((job) => {
<div className="p-2 space-y-2">
{filteredJobs.map((job) => {
const statusInfo = getBuildStatusInfo(job.lastBuildStatus);
return (
<TableRow key={job.id}>
<TableCell width="250px" className="font-medium">
<div className="flex items-center gap-2">
<span className="truncate">{job.jobName}</span>
<div
key={job.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedJob?.id === job.id
? 'bg-accent border-primary'
: ''
}`}
onClick={() => handleSelectJob(job)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Box className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="font-medium truncate">
{job.jobName}
</span>
</div>
</TableCell>
<TableCell width="300px" className="text-muted-foreground text-sm">
{job.description || '-'}
</TableCell>
<TableCell width="100px">
<span className="text-sm">#{job.lastBuildNumber}</span>
</TableCell>
<TableCell width="120px">
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
{job.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{job.description}
</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge
variant={statusInfo.variant}
className="inline-flex items-center gap-1"
>
{statusInfo.icon}
{statusInfo.label}
</Badge>
</TableCell>
<TableCell width="120px">
<div className="space-y-1">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<GitBranch className="h-3 w-3" />
#{job.lastBuildNumber}
</span>
<div className="flex items-center gap-1">
<Progress
value={job.healthReportScore}
className="h-2"
indicatorClassName={getHealthColor(job.healthReportScore)}
className="h-2 w-16"
indicatorClassName={getHealthColor(
job.healthReportScore
)}
/>
<span className="text-xs text-muted-foreground">{job.healthReportScore}%</span>
<span className="text-xs text-muted-foreground">
{job.healthReportScore}%
</span>
</div>
</TableCell>
<TableCell width="180px" className="text-sm text-muted-foreground">
</div>
{job.lastBuildTime && (
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatTime(job.lastBuildTime)}
</TableCell>
<TableCell width="100px" sticky>
</div>
)}
</div>
{job.jobUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={job.jobUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
// 确保使用绝对 URL
const url = job.jobUrl.startsWith('http')
? job.jobUrl
: `${jenkinsList.find(j => j.id === Number(currentJenkinsId))?.url || ''}${job.jobUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
}}
size="icon"
className="h-6 w-6"
>
<ExternalLink className="h-4 w-4 mr-1" />
<ExternalLink className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
);
})
</a>
</TooltipTrigger>
<TooltipContent>
<p> Jenkins </p>
</TooltipContent>
</Tooltip>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<Layers className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4">
Jenkins
</p>
<Button onClick={handleSyncAll} disabled={syncing.all}>
{syncing.all ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
</div>
);
})}
</div>
)}
</div>
</Card>
{/* 右栏Builds 列表(待实现) */}
<Card className="col-span-4 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Builds</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBuilds}
disabled={syncing.builds || !selectedJob}
>
<RefreshCw
className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`}
/>
</Button>
</CardContent>
</Card>
</div>
<div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索构建..."
value={buildSearch}
onChange={(e) => setBuildSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
</CardHeader>
<div className="flex-1 overflow-auto">
{!selectedJob ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Activity className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Activity className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
)}
</div>
</Card>
</div>
</PageContainer>
</TooltipProvider>
);
};

View File

@ -0,0 +1,441 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Activity,
CheckCircle2,
XCircle,
Pause,
Clock,
Loader2,
TrendingUp,
AlertCircle,
Calendar,
} from 'lucide-react';
import { getDashboard, getScheduleJobs } from '../service';
import type { DashboardResponse, ScheduleJobResponse, LogStatus } from '../types';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
const Dashboard: React.FC = () => {
const [data, setData] = useState<DashboardResponse | null>(null);
const [allJobs, setAllJobs] = useState<ScheduleJobResponse[]>([]);
const [loading, setLoading] = useState(true);
// 加载仪表盘数据
const loadData = async () => {
try {
const [dashboardData, jobsData] = await Promise.all([
getDashboard(),
getScheduleJobs({ pageNum: 0, pageSize: 100 })
]);
setData(dashboardData);
setAllJobs(jobsData?.content || []);
} catch (error) {
console.error('加载仪表盘数据失败:', error);
} finally {
setLoading(false);
}
};
// 初始加载和5秒轮询
useEffect(() => {
loadData();
const interval = setInterval(loadData, 5000);
return () => clearInterval(interval);
}, []);
// 获取日志状态徽章
const getLogStatusBadge = (status: LogStatus) => {
const statusMap: Record<LogStatus, {
variant: 'default' | 'secondary' | 'destructive' | 'success';
text: string;
icon: React.ElementType;
}> = {
SUCCESS: { variant: 'success', text: '成功', icon: CheckCircle2 },
FAIL: { variant: 'destructive', text: '失败', icon: XCircle },
TIMEOUT: { variant: 'secondary', text: '超时', icon: Clock },
};
const info = statusMap[status];
const Icon = info.icon;
return (
<Badge variant={info.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{info.text}
</Badge>
);
};
// 按状态分组任务
const groupedJobs = React.useMemo(() => {
return {
running: allJobs.filter(j => data?.runningJobs.some(r => r.jobId === j.id)),
paused: allJobs.filter(j => j.status === 'PAUSED'),
idle: allJobs.filter(j => j.status === 'ENABLED' && !data?.runningJobs.some(r => r.jobId === j.id)),
disabled: allJobs.filter(j => j.status === 'DISABLED'),
};
}, [allJobs, data]);
// 获取即将执行的任务(按下次执行时间排序)
const upcomingJobs = React.useMemo(() => {
return allJobs
.filter(j => j.nextExecuteTime && j.status === 'ENABLED')
.sort((a, b) => {
if (!a.nextExecuteTime || !b.nextExecuteTime) return 0;
return dayjs(a.nextExecuteTime).valueOf() - dayjs(b.nextExecuteTime).valueOf();
})
.slice(0, 10);
}, [allJobs]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* 任务概览统计卡片 */}
<div className="grid gap-4 md:grid-cols-5">
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="flex flex-row items-center justify-between 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">{data?.summary.total || 0}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Loader2 className="h-4 w-4 text-muted-foreground animate-spin" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.summary.running || 0}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.summary.enabled || 0}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-yellow-500">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Pause className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.summary.paused || 0}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-gray-500">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<XCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.summary.disabled || 0}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
{/* 正在执行的任务 */}
{data && data.runningJobs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
({data.runningJobs.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data.runningJobs.map((job) => (
<div key={job.jobId} className="p-4 border rounded-lg space-y-3">
<div className="flex items-start justify-between">
<div>
<h4 className="font-semibold text-lg">{job.jobName}</h4>
<p className="text-sm text-muted-foreground mt-1">
: {dayjs(job.startTime).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<Badge variant="outline" className="text-orange-600 border-orange-600">
{job.progress}%
</Badge>
</div>
<Progress value={job.progress} className="h-3" />
{job.message && (
<p className="text-sm text-muted-foreground flex items-center gap-2">
<Activity className="h-4 w-4" />
{job.message}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* 多视图 Tabs */}
<Tabs defaultValue="timeline" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="timeline">
<Calendar className="h-4 w-4 mr-2" />
线
</TabsTrigger>
<TabsTrigger value="status">
<TrendingUp className="h-4 w-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="logs">
<AlertCircle className="h-4 w-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* 时间线视图 */}
<TabsContent value="timeline">
<Card>
<CardHeader>
<CardTitle>线</CardTitle>
<p className="text-sm text-muted-foreground">
</p>
</CardHeader>
<CardContent>
{upcomingJobs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Clock className="h-12 w-12 mx-auto mb-2 opacity-20" />
<p></p>
</div>
) : (
<div className="relative pl-8">
{/* 时间线 */}
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
{upcomingJobs.map((job, index) => (
<div key={job.id} className="relative mb-6 last:mb-0">
{/* 时间点 */}
<div className="absolute -left-[1.65rem] top-1.5 w-3 h-3 rounded-full bg-primary border-2 border-background" />
<div className="flex items-start gap-3">
<div className="min-w-[80px] text-sm font-medium text-muted-foreground">
{dayjs(job.nextExecuteTime).format('HH:mm')}
</div>
<div className="flex-1 p-3 border rounded-lg hover:bg-accent transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{job.jobName}</p>
<p className="text-sm text-muted-foreground mt-1">
{dayjs(job.nextExecuteTime).fromNow()}
</p>
</div>
<Badge variant="outline">
{index === 0 && data?.runningJobs.some(r => r.jobId === job.id) ? (
<span className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
) : (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
</span>
)}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 状态分组视图 */}
<TabsContent value="status">
<div className="space-y-4">
{/* 运行中 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" />
({groupedJobs.running.length})
</CardTitle>
</CardHeader>
<CardContent>
{groupedJobs.running.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-2">
{groupedJobs.running.map((job) => {
const runningJob = data?.runningJobs.find(r => r.jobId === job.id);
return (
<div key={job.id} className="flex items-center gap-3 p-2 border rounded">
<div className="flex-1">
<p className="font-medium">{job.jobName}</p>
{runningJob && (
<Progress value={runningJob.progress} className="h-2 mt-2" />
)}
</div>
{runningJob && (
<Badge variant="outline">{runningJob.progress}%</Badge>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* 已暂停 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Pause className="h-4 w-4 text-yellow-600" />
({groupedJobs.paused.length})
</CardTitle>
</CardHeader>
<CardContent>
{groupedJobs.paused.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-2">
{groupedJobs.paused.map((job) => (
<div key={job.id} className="flex items-center justify-between p-2 border rounded">
<p className="font-medium">{job.jobName}</p>
<p className="text-sm text-muted-foreground">
: {job.lastExecuteTime ? dayjs(job.lastExecuteTime).fromNow() : '-'}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 空闲中 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
({groupedJobs.idle.length})
</CardTitle>
</CardHeader>
<CardContent>
{groupedJobs.idle.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-2 md:grid-cols-2">
{groupedJobs.idle.map((job) => (
<div key={job.id} className="p-2 border rounded">
<p className="font-medium truncate">{job.jobName}</p>
<p className="text-sm text-muted-foreground">
: {job.nextExecuteTime ? dayjs(job.nextExecuteTime).format('MM-DD HH:mm') : '-'}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 已禁用 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<XCircle className="h-4 w-4 text-gray-500" />
({groupedJobs.disabled.length})
</CardTitle>
</CardHeader>
<CardContent>
{groupedJobs.disabled.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="grid gap-2 md:grid-cols-2">
{groupedJobs.disabled.map((job) => (
<div key={job.id} className="p-2 border rounded opacity-60">
<p className="font-medium truncate">{job.jobName}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* 最近日志 */}
<TabsContent value="logs">
<Card>
<CardHeader>
<CardTitle> (10)</CardTitle>
</CardHeader>
<CardContent>
{!data || data.recentLogs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-20" />
<p></p>
</div>
) : (
<div className="space-y-3">
{data.recentLogs.map((log) => (
<div key={log.id} className="p-3 border rounded-lg hover:bg-accent transition-colors">
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-medium">{log.jobName}</p>
<p className="text-sm text-muted-foreground">
{dayjs(log.executeTime).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
{getLogStatusBadge(log.status)}
</div>
{log.resultMessage && (
<p className="text-sm text-muted-foreground">
{log.resultMessage}
</p>
)}
{log.exceptionInfo && (
<p className="text-sm text-red-600 mt-1">
{log.exceptionInfo}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default Dashboard;

View File

@ -4,37 +4,37 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DataTablePagination } from '@/components/ui/pagination';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
FileText,
Loader2,
Search,
CheckCircle2,
XCircle,
Clock,
Server,
Calendar,
AlertCircle,
Timer,
Code,
Info,
Activity,
RefreshCw,
} from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { getJobLogs } from '../service';
import type { ScheduleJobResponse, ScheduleJobLogResponse, ScheduleJobLogQuery, LogStatus } from '../types';
import type { Page } from '@/types/base';
import dayjs from 'dayjs';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
interface JobLogDialogProps {
open: boolean;
@ -131,112 +131,94 @@ const JobLogDialog: React.FC<JobLogDialogProps> = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogContent className="max-w-7xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
<FileText className="h-5 w-5 text-primary" />
- {job?.jobName}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col space-y-4">
<DialogBody className="space-y-4">
{/* 筛选栏 */}
<div className="flex items-center gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Select
value={query.status || 'all'}
onValueChange={(value) => setQuery({ ...query, status: value !== 'all' ? value as LogStatus : undefined, pageNum: 0 })}
>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="SUCCESS"></SelectItem>
<SelectItem value="FAIL"></SelectItem>
<SelectItem value="TIMEOUT"></SelectItem>
<SelectItem value="SUCCESS"> </SelectItem>
<SelectItem value="FAIL"> </SelectItem>
<SelectItem value="TIMEOUT"> </SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={loadLogs}>
<Search className="h-4 w-4 mr-2" />
</div>
<Button variant="outline" size="sm" onClick={loadLogs} disabled={loading}>
<RefreshCw className={cn("h-4 w-4 mr-2", loading && "animate-spin")} />
</Button>
</div>
{/* 日志列表 */}
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]">IP</TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-2 text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
) : data && data.content.length > 0 ? (
data.content.map((log) => (
<TableRow
<div className="grid grid-cols-2 gap-4">
{/* 左侧:日志列表 */}
<div className="space-y-2">
<ScrollArea className="h-[500px] pr-4">
{data.content.map((log) => (
<Card
key={log.id}
className={selectedLog?.id === log.id ? 'bg-accent' : ''}
>
<TableCell className="text-xs">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3 text-muted-foreground" />
{dayjs(log.executeTime).format('YYYY-MM-DD HH:mm:ss')}
</div>
</TableCell>
<TableCell className="text-xs">
{log.finishTime ? dayjs(log.finishTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
</TableCell>
<TableCell className="text-xs font-mono">
{formatDuration(log.duration)}
</TableCell>
<TableCell>{getStatusBadge(log.status)}</TableCell>
<TableCell className="text-xs">
{log.serverIp ? (
<div className="flex items-center gap-1">
<Server className="h-3 w-3 text-muted-foreground" />
{log.serverIp}
</div>
) : (
'-'
className={cn(
"mb-3 cursor-pointer transition-all hover:shadow-md",
selectedLog?.id === log.id
? "border-primary bg-accent"
: "hover:border-muted-foreground/30"
)}
</TableCell>
<TableCell className="max-w-[300px] truncate text-xs" title={log.resultMessage}>
{log.resultMessage || '-'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedLog(log)}
>
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
{getStatusBadge(log.status)}
<span className="text-xs text-muted-foreground font-mono">
{formatDuration(log.duration)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{dayjs(log.executeTime).format('MM-DD HH:mm:ss')}
</div>
</div>
<div className="space-y-2">
{log.serverIp && (
<div className="flex items-center gap-2 text-xs">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono">{log.serverIp}</span>
</div>
)}
{log.resultMessage && (
<p className="text-xs text-muted-foreground line-clamp-2">
{log.resultMessage}
</p>
)}
</div>
</CardContent>
</Card>
))}
</ScrollArea>
{/* 分页 */}
{data && data.content.length > 0 && (
@ -247,77 +229,162 @@ const JobLogDialog: React.FC<JobLogDialogProps> = ({
onPageChange={(pageIndex) => setQuery({ ...query, pageNum: pageIndex })}
/>
)}
</div>
{/* 日志详情 */}
{selectedLog && (
<div className="border-t pt-4 space-y-3">
{/* 右侧:详情面板 */}
<div className="border rounded-lg">
{selectedLog ? (
<ScrollArea className="h-[556px]">
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<h3 className="text-base font-semibold flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
</h3>
<Button variant="ghost" size="sm" onClick={() => setSelectedLog(null)}>
</Button>
{getStatusBadge(selectedLog.status)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Bean名称</span>
<code className="ml-2 text-xs bg-muted px-1 py-0.5 rounded">{selectedLog.beanName}</code>
<Separator />
{/* 基本信息 */}
<div className="space-y-3">
<div className="flex items-start gap-3">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-sm font-medium">
{dayjs(selectedLog.executeTime).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<code className="ml-2 text-xs bg-muted px-1 py-0.5 rounded">{selectedLog.methodName}</code>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="ml-2">{selectedLog.serverHost || '-'}</span>
{selectedLog.finishTime && (
<div className="flex items-start gap-3">
<CheckCircle2 className="h-4 w-4 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-sm font-medium">
{dayjs(selectedLog.finishTime).format('YYYY-MM-DD HH:mm:ss')}
</p>
</div>
<div>
<span className="text-muted-foreground">IP</span>
<span className="ml-2">{selectedLog.serverIp || '-'}</span>
</div>
)}
<div className="flex items-start gap-3">
<Timer className="h-4 w-4 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-sm font-medium font-mono">
{formatDuration(selectedLog.duration)}
</p>
</div>
</div>
{selectedLog.serverIp && (
<div className="flex items-start gap-3">
<Server className="h-4 w-4 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-sm font-medium font-mono">{selectedLog.serverIp}</p>
{selectedLog.serverHost && (
<p className="text-xs text-muted-foreground mt-1">{selectedLog.serverHost}</p>
)}
</div>
</div>
)}
</div>
<Separator />
{/* 技术信息 */}
<div className="space-y-3">
<div className="flex items-start gap-3">
<Code className="h-4 w-4 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1">Bean名称</p>
<code className="text-xs bg-muted px-2 py-1 rounded block overflow-x-auto">
{selectedLog.beanName}
</code>
</div>
</div>
<div className="flex items-start gap-3">
<Activity className="h-4 w-4 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="text-xs text-muted-foreground mb-1"></p>
<code className="text-xs bg-muted px-2 py-1 rounded block overflow-x-auto">
{selectedLog.methodName}
</code>
</div>
</div>
{selectedLog.methodParams && (
<div>
<label className="text-sm text-muted-foreground mb-1 block"></label>
<Textarea
value={selectedLog.methodParams}
readOnly
className="font-mono text-xs h-20"
/>
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Code className="h-3.5 w-3.5" />
</p>
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto font-mono">
{selectedLog.methodParams}
</pre>
</div>
)}
</div>
{/* 结果信息 */}
{selectedLog.resultMessage && (
<>
<Separator />
<div>
<label className="text-sm text-muted-foreground mb-1 block"></label>
<Textarea
value={selectedLog.resultMessage}
readOnly
className="text-xs h-20"
/>
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" />
</p>
<div className="text-sm bg-muted/50 p-3 rounded border">
{selectedLog.resultMessage}
</div>
</div>
</>
)}
{/* 异常信息 */}
{selectedLog.exceptionInfo && (
<>
<Separator />
<div>
<label className="text-sm text-destructive mb-1 block flex items-center gap-1">
<XCircle className="h-4 w-4" />
</label>
<Textarea
value={selectedLog.exceptionInfo}
readOnly
className="font-mono text-xs h-40 text-destructive"
/>
<p className="text-xs text-destructive mb-2 flex items-center gap-1 font-medium">
<XCircle className="h-3.5 w-3.5" />
</p>
<pre className="text-xs bg-destructive/5 border border-destructive/20 p-3 rounded overflow-x-auto font-mono text-destructive max-h-60">
{selectedLog.exceptionInfo}
</pre>
</div>
</>
)}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-[556px] text-center">
<div>
<Info className="h-12 w-12 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-64 border rounded-lg">
<div className="text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-3 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
);

View File

@ -6,18 +6,20 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DataTablePagination } from '@/components/ui/pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Loader2, Plus, Search, Edit, Trash2, Play, Pause,
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List
} from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { getScheduleJobs, getJobCategoryList, executeJobNow, pauseJob, resumeJob, deleteScheduleJob } from './service';
import { getScheduleJobs, getJobCategoryList, startJob, pauseJob, resumeJob, stopJob, triggerJob, deleteScheduleJob } from './service';
import type { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import CategoryManageDialog from './components/CategoryManageDialog';
import JobLogDialog from './components/JobLogDialog';
import JobEditDialog from './components/JobEditDialog';
import Dashboard from './components/Dashboard';
import {
AlertDialog,
AlertDialogAction,
@ -139,20 +141,20 @@ const ScheduleJobList: React.FC = () => {
}
};
// 立即执行
const handleExecute = async (record: ScheduleJobResponse) => {
// 启动任务
const handleStart = async (record: ScheduleJobResponse) => {
try {
await executeJobNow(record.id);
await startJob(record.id);
toast({
title: '执行成功',
description: `任务 "${record.jobName}" 已开始执行`,
title: '启动成功',
description: `任务 "${record.jobName}" 已启动`,
});
loadData();
} catch (error) {
console.error('执行失败:', error);
console.error('启动失败:', error);
toast({
variant: 'destructive',
title: '执行失败',
title: '启动失败',
description: error instanceof Error ? error.message : '未知错误',
});
}
@ -196,6 +198,44 @@ const ScheduleJobList: React.FC = () => {
}
};
// 停止任务
const handleStop = async (record: ScheduleJobResponse) => {
try {
await stopJob(record.id);
toast({
title: '停止成功',
description: `任务 "${record.jobName}" 已停止`,
});
loadData();
} catch (error) {
console.error('停止失败:', error);
toast({
variant: 'destructive',
title: '停止失败',
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 立即触发任务
const handleTrigger = async (record: ScheduleJobResponse) => {
try {
await triggerJob(record.id);
toast({
title: '触发成功',
description: `任务 "${record.jobName}" 已立即触发执行`,
});
loadData();
} catch (error) {
console.error('触发失败:', error);
toast({
variant: 'destructive',
title: '触发失败',
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 查看日志
const handleViewLog = (record: ScheduleJobResponse) => {
setSelectedJob(record);
@ -240,8 +280,23 @@ const ScheduleJobList: React.FC = () => {
</p>
</div>
{/* 视图切换 */}
<Tabs defaultValue="list" className="space-y-6">
<TabsList>
<TabsTrigger value="list" className="gap-2">
<List className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="dashboard" className="gap-2">
<BarChart3 className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 列表视图 */}
<TabsContent value="list" className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-blue-700"></CardTitle>
@ -408,8 +463,8 @@ const ScheduleJobList: React.FC = () => {
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleExecute(record)}
title="立即执行"
onClick={() => handleTrigger(record)}
title="立即触发"
>
<PlayCircle className="h-4 w-4" />
</Button>
@ -564,6 +619,13 @@ const ScheduleJobList: React.FC = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TabsContent>
{/* 仪表盘视图 */}
<TabsContent value="dashboard">
<Dashboard />
</TabsContent>
</Tabs>
</div>
);
};

View File

@ -9,6 +9,7 @@ import type {
ScheduleJobQuery,
ScheduleJobLogResponse,
ScheduleJobLogQuery,
DashboardResponse,
} from './types';
const JOB_CATEGORY_URL = '/api/v1/schedule/job-categories';
@ -92,10 +93,10 @@ export const batchDeleteScheduleJobs = (ids: number[]) =>
request.post(`${SCHEDULE_JOB_URL}/batch-delete`, { ids });
/**
*
*
*/
export const executeJobNow = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/execute`);
export const startJob = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/start`);
/**
*
@ -109,6 +110,26 @@ export const pauseJob = (id: number) =>
export const resumeJob = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/resume`);
/**
*
*/
export const stopJob = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/stop`);
/**
*
*/
export const triggerJob = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/trigger`);
/**
* Cron表达式
*/
export const updateJobCron = (id: number, cronExpression: string) =>
request.put<void>(`${SCHEDULE_JOB_URL}/${id}/cron`, null, {
params: { cronExpression }
});
// ==================== 任务日志 ====================
/**
@ -117,3 +138,11 @@ export const resumeJob = (id: number) =>
export const getJobLogs = (params?: ScheduleJobLogQuery) =>
request.get<Page<ScheduleJobLogResponse>>(`${JOB_LOG_URL}/page`, { params });
// ==================== 仪表盘 ====================
/**
*
*/
export const getDashboard = () =>
request.get<DashboardResponse>(`${SCHEDULE_JOB_URL}/dashboard`);

View File

@ -148,3 +148,35 @@ export interface ScheduleJobLogQuery extends BaseQuery {
executeTimeEnd?: string;
}
/**
*
*/
export interface DashboardSummary {
total: number;
enabled: number;
disabled: number;
paused: number;
running: number;
}
/**
*
*/
export interface RunningJob {
jobId: number;
jobName: string;
startTime: string;
progress: number;
message?: string;
status: string;
}
/**
*
*/
export interface DashboardResponse {
summary: DashboardSummary;
runningJobs: RunningJob[];
recentLogs: ScheduleJobLogResponse[];
}