增加团队管理页面

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} {group.name}
</span> </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 && ( {group.webUrl && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -739,6 +745,13 @@ const GitManager: React.FC = () => {
</span> </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 && ( {project.lastActivityAt && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />

File diff suppressed because it is too large Load Diff

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

View File

@ -6,18 +6,20 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DataTablePagination } from '@/components/ui/pagination'; import { DataTablePagination } from '@/components/ui/pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Loader2, Plus, Search, Edit, Trash2, Play, Pause, 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'; } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast'; 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 { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types';
import type { Page } from '@/types/base'; import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page'; import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import CategoryManageDialog from './components/CategoryManageDialog'; import CategoryManageDialog from './components/CategoryManageDialog';
import JobLogDialog from './components/JobLogDialog'; import JobLogDialog from './components/JobLogDialog';
import JobEditDialog from './components/JobEditDialog'; import JobEditDialog from './components/JobEditDialog';
import Dashboard from './components/Dashboard';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -139,20 +141,20 @@ const ScheduleJobList: React.FC = () => {
} }
}; };
// 立即执行 // 启动任务
const handleExecute = async (record: ScheduleJobResponse) => { const handleStart = async (record: ScheduleJobResponse) => {
try { try {
await executeJobNow(record.id); await startJob(record.id);
toast({ toast({
title: '执行成功', title: '启动成功',
description: `任务 "${record.jobName}" 已开始执行`, description: `任务 "${record.jobName}" 已启动`,
}); });
loadData(); loadData();
} catch (error) { } catch (error) {
console.error('执行失败:', error); console.error('启动失败:', error);
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '执行失败', title: '启动失败',
description: error instanceof Error ? error.message : '未知错误', 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) => { const handleViewLog = (record: ScheduleJobResponse) => {
setSelectedJob(record); setSelectedJob(record);
@ -240,8 +280,23 @@ const ScheduleJobList: React.FC = () => {
</p> </p>
</div> </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"> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-blue-700"></CardTitle> <CardTitle className="text-sm font-medium text-blue-700"></CardTitle>
@ -408,8 +463,8 @@ const ScheduleJobList: React.FC = () => {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => handleExecute(record)} onClick={() => handleTrigger(record)}
title="立即执行" title="立即触发"
> >
<PlayCircle className="h-4 w-4" /> <PlayCircle className="h-4 w-4" />
</Button> </Button>
@ -564,6 +619,13 @@ const ScheduleJobList: React.FC = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</TabsContent>
{/* 仪表盘视图 */}
<TabsContent value="dashboard">
<Dashboard />
</TabsContent>
</Tabs>
</div> </div>
); );
}; };

View File

@ -9,6 +9,7 @@ import type {
ScheduleJobQuery, ScheduleJobQuery,
ScheduleJobLogResponse, ScheduleJobLogResponse,
ScheduleJobLogQuery, ScheduleJobLogQuery,
DashboardResponse,
} from './types'; } from './types';
const JOB_CATEGORY_URL = '/api/v1/schedule/job-categories'; 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 }); request.post(`${SCHEDULE_JOB_URL}/batch-delete`, { ids });
/** /**
* *
*/ */
export const executeJobNow = (id: number) => export const startJob = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/execute`); request.post<void>(`${SCHEDULE_JOB_URL}/${id}/start`);
/** /**
* *
@ -109,6 +110,26 @@ export const pauseJob = (id: number) =>
export const resumeJob = (id: number) => export const resumeJob = (id: number) =>
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/resume`); 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) => export const getJobLogs = (params?: ScheduleJobLogQuery) =>
request.get<Page<ScheduleJobLogResponse>>(`${JOB_LOG_URL}/page`, { params }); 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; 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[];
}