增加团队管理页面
This commit is contained in:
parent
6d882dd6e3
commit
e89f7a90e8
@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
@ -14,6 +13,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
@ -25,16 +25,19 @@ import {
|
|||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
Users,
|
Users,
|
||||||
|
|
||||||
Search,
|
Search,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
|
||||||
Shield,
|
Shield,
|
||||||
Code2,
|
Code2,
|
||||||
Calendar,
|
Calendar,
|
||||||
@ -45,19 +48,20 @@ import type {
|
|||||||
RepositoryGroupResponse,
|
RepositoryGroupResponse,
|
||||||
RepositoryProjectResponse,
|
RepositoryProjectResponse,
|
||||||
RepositoryBranchResponse,
|
RepositoryBranchResponse,
|
||||||
GitStatistics,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import {
|
||||||
getGitInstances,
|
getGitInstances,
|
||||||
|
|
||||||
getRepositoryGroups,
|
getRepositoryGroups,
|
||||||
getRepositoryProjects,
|
getRepositoryProjects,
|
||||||
getRepositoryBranches,
|
getRepositoryBranches,
|
||||||
syncAllGitData,
|
syncAllGitData,
|
||||||
|
|
||||||
syncRepositoryGroups,
|
syncRepositoryGroups,
|
||||||
syncRepositoryProjects,
|
syncRepositoryProjects,
|
||||||
syncRepositoryBranches,
|
syncRepositoryBranches,
|
||||||
getGitStatistics,
|
|
||||||
} from './service';
|
} from './service';
|
||||||
|
|
||||||
import type { ExternalSystemResponse } from '@/pages/Deploy/External/types';
|
import type { ExternalSystemResponse } from '@/pages/Deploy/External/types';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
@ -66,9 +70,11 @@ import 'dayjs/locale/zh-cn';
|
|||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
|
||||||
// 可见性配置
|
// 可见性配置
|
||||||
const VISIBILITY_CONFIG = {
|
const VISIBILITY_CONFIG = {
|
||||||
public: { label: '公开', variant: 'default' as const, icon: Globe, color: 'text-green-600' },
|
public: { label: '公开', variant: 'default' as const, icon: Globe, color: 'text-green-600' },
|
||||||
|
|
||||||
private: { label: '私有', variant: 'secondary' as const, icon: Lock, color: 'text-red-600' },
|
private: { label: '私有', variant: 'secondary' as const, icon: Lock, color: 'text-red-600' },
|
||||||
internal: { label: '内部', variant: 'outline' as const, icon: Users, color: 'text-yellow-600' },
|
internal: { label: '内部', variant: 'outline' as const, icon: Users, color: 'text-yellow-600' },
|
||||||
};
|
};
|
||||||
@ -76,6 +82,7 @@ const VISIBILITY_CONFIG = {
|
|||||||
const GitManager: React.FC = () => {
|
const GitManager: React.FC = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|
||||||
// Git 实例选择
|
// Git 实例选择
|
||||||
const [instances, setInstances] = useState<ExternalSystemResponse[]>([]);
|
const [instances, setInstances] = useState<ExternalSystemResponse[]>([]);
|
||||||
const [selectedInstanceId, setSelectedInstanceId] = useState<number>();
|
const [selectedInstanceId, setSelectedInstanceId] = useState<number>();
|
||||||
@ -84,7 +91,6 @@ const GitManager: React.FC = () => {
|
|||||||
const [groupTree, setGroupTree] = useState<RepositoryGroupResponse[]>([]);
|
const [groupTree, setGroupTree] = useState<RepositoryGroupResponse[]>([]);
|
||||||
const [projects, setProjects] = useState<RepositoryProjectResponse[]>([]);
|
const [projects, setProjects] = useState<RepositoryProjectResponse[]>([]);
|
||||||
const [branches, setBranches] = useState<RepositoryBranchResponse[]>([]);
|
const [branches, setBranches] = useState<RepositoryBranchResponse[]>([]);
|
||||||
const [statistics, setStatistics] = useState<GitStatistics>();
|
|
||||||
|
|
||||||
// 选中状态
|
// 选中状态
|
||||||
const [selectedGroup, setSelectedGroup] = useState<RepositoryGroupResponse>();
|
const [selectedGroup, setSelectedGroup] = useState<RepositoryGroupResponse>();
|
||||||
@ -96,7 +102,6 @@ const GitManager: React.FC = () => {
|
|||||||
groups: false,
|
groups: false,
|
||||||
projects: false,
|
projects: false,
|
||||||
branches: false,
|
branches: false,
|
||||||
statistics: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 同步状态
|
// 同步状态
|
||||||
@ -107,14 +112,18 @@ const GitManager: React.FC = () => {
|
|||||||
branches: false,
|
branches: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// 搜索过滤
|
// 搜索过滤
|
||||||
const [groupSearch, setGroupSearch] = useState('');
|
const [groupSearch, setGroupSearch] = useState('');
|
||||||
const [projectSearch, setProjectSearch] = useState('');
|
const [projectSearch, setProjectSearch] = useState('');
|
||||||
|
|
||||||
const [branchSearch, setBranchSearch] = useState('');
|
const [branchSearch, setBranchSearch] = useState('');
|
||||||
|
|
||||||
|
|
||||||
// 加载 Git 实例列表
|
// 加载 Git 实例列表
|
||||||
const loadInstances = async () => {
|
const loadInstances = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const data = await getGitInstances();
|
const data = await getGitInstances();
|
||||||
if (data && Array.isArray(data)) {
|
if (data && Array.isArray(data)) {
|
||||||
setInstances(data);
|
setInstances(data);
|
||||||
@ -124,6 +133,7 @@ const GitManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
|
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: '加载失败',
|
title: '加载失败',
|
||||||
description: '加载 Git 实例列表失败',
|
description: '加载 Git 实例列表失败',
|
||||||
@ -131,6 +141,7 @@ const GitManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 构建树形结构
|
// 构建树形结构
|
||||||
const buildTree = (groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
|
const buildTree = (groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
|
||||||
// 使用 repoGroupId 作为 key,因为 parentId 对应的是 Git 系统中的 repoGroupId
|
// 使用 repoGroupId 作为 key,因为 parentId 对应的是 Git 系统中的 repoGroupId
|
||||||
@ -226,6 +237,7 @@ const GitManager: React.FC = () => {
|
|||||||
setBranches(data || []);
|
setBranches(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
|
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: '加载失败',
|
title: '加载失败',
|
||||||
description: '加载分支列表失败',
|
description: '加载分支列表失败',
|
||||||
@ -236,38 +248,18 @@ const GitManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载统计信息
|
// 同步所有数据(异步任务)
|
||||||
const loadStatistics = async () => {
|
|
||||||
if (!selectedInstanceId) return;
|
|
||||||
|
|
||||||
setLoading((prev) => ({ ...prev, statistics: true }));
|
|
||||||
try {
|
|
||||||
const data = await getGitStatistics(selectedInstanceId);
|
|
||||||
setStatistics(data);
|
|
||||||
} catch (error) {
|
|
||||||
// 静默失败
|
|
||||||
} finally {
|
|
||||||
setLoading((prev) => ({ ...prev, statistics: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 同步所有数据
|
|
||||||
const handleSyncAll = async () => {
|
const handleSyncAll = async () => {
|
||||||
|
|
||||||
if (!selectedInstanceId) return;
|
if (!selectedInstanceId) return;
|
||||||
|
|
||||||
setSyncing((prev) => ({ ...prev, all: true }));
|
setSyncing((prev) => ({ ...prev, all: true }));
|
||||||
try {
|
try {
|
||||||
await syncAllGitData(selectedInstanceId);
|
await syncAllGitData(selectedInstanceId);
|
||||||
toast({
|
toast({
|
||||||
title: '同步成功',
|
title: '同步任务已启动',
|
||||||
description: '已开始同步所有数据',
|
description: '正在后台同步所有数据,请稍后手动刷新查看结果',
|
||||||
});
|
});
|
||||||
// 重新加载数据
|
|
||||||
await Promise.all([
|
|
||||||
loadGroupTree(),
|
|
||||||
loadProjects(selectedGroup?.repoGroupId || selectedGroup?.id),
|
|
||||||
loadStatistics(),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@ -279,31 +271,32 @@ const GitManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步仓库组
|
// 同步仓库组(异步任务)
|
||||||
const handleSyncGroups = async () => {
|
const handleSyncGroups = async () => {
|
||||||
|
|
||||||
if (!selectedInstanceId) return;
|
if (!selectedInstanceId) return;
|
||||||
|
|
||||||
setSyncing((prev) => ({ ...prev, groups: true }));
|
setSyncing((prev) => ({ ...prev, groups: true }));
|
||||||
try {
|
try {
|
||||||
await syncRepositoryGroups(selectedInstanceId);
|
await syncRepositoryGroups(selectedInstanceId);
|
||||||
toast({
|
toast({
|
||||||
title: '同步成功',
|
title: '同步任务已启动',
|
||||||
description: '已开始同步仓库组',
|
description: '正在后台同步仓库组,请稍后手动刷新查看结果',
|
||||||
});
|
});
|
||||||
await loadGroupTree();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: '同步失败',
|
title: '同步失败',
|
||||||
description: '仓库组同步失败',
|
description: '仓库组同步任务启动失败',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing((prev) => ({ ...prev, groups: false }));
|
setSyncing((prev) => ({ ...prev, groups: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步项目
|
// 同步项目(异步任务)
|
||||||
const handleSyncProjects = async () => {
|
const handleSyncProjects = async () => {
|
||||||
|
|
||||||
if (!selectedInstanceId || !selectedGroup) return;
|
if (!selectedInstanceId || !selectedGroup) return;
|
||||||
|
|
||||||
setSyncing((prev) => ({ ...prev, projects: true }));
|
setSyncing((prev) => ({ ...prev, projects: true }));
|
||||||
@ -314,43 +307,47 @@ const GitManager: React.FC = () => {
|
|||||||
selectedGroup.repoGroupId
|
selectedGroup.repoGroupId
|
||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: '同步成功',
|
title: '同步任务已启动',
|
||||||
description: `已开始同步仓库组"${selectedGroup.name}"的项目`,
|
description: `正在后台同步仓库组"${selectedGroup.name}"的项目,请稍后手动刷新查看结果`,
|
||||||
});
|
});
|
||||||
await loadProjects(selectedGroup.repoGroupId || selectedGroup.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: '同步失败',
|
title: '同步失败',
|
||||||
description: '项目同步失败',
|
description: '项目同步任务启动失败',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing((prev) => ({ ...prev, projects: false }));
|
setSyncing((prev) => ({ ...prev, projects: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 同步分支
|
// 同步分支(异步任务)
|
||||||
const handleSyncBranches = async () => {
|
const handleSyncBranches = async () => {
|
||||||
if (!selectedInstanceId || !selectedProject || !selectedGroup) return;
|
|
||||||
|
if (!selectedInstanceId || !selectedGroup) return;
|
||||||
|
|
||||||
setSyncing((prev) => ({ ...prev, branches: true }));
|
setSyncing((prev) => ({ ...prev, branches: true }));
|
||||||
try {
|
try {
|
||||||
// 传递 repoProjectId, repoGroupId (Git系统中的ID) 和 externalSystemId
|
// 如果选择了项目,只同步该项目的分支;否则同步该组下所有项目的分支
|
||||||
await syncRepositoryBranches(
|
await syncRepositoryBranches(
|
||||||
selectedInstanceId,
|
selectedInstanceId,
|
||||||
selectedProject.repoProjectId,
|
selectedProject?.repoProjectId,
|
||||||
selectedGroup.repoGroupId
|
selectedGroup.repoGroupId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const description = selectedProject
|
||||||
|
? `正在后台同步项目"${selectedProject.name}"的分支,请稍后手动刷新查看结果`
|
||||||
|
: `正在后台同步组"${selectedGroup.name}"下所有项目的分支,请稍后手动刷新查看结果`;
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: '同步成功',
|
title: '同步任务已启动',
|
||||||
description: `已开始同步项目"${selectedProject.name}"的分支`,
|
description,
|
||||||
});
|
});
|
||||||
await loadBranches(selectedProject.repoProjectId || selectedProject.id);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: '同步失败',
|
title: '同步失败',
|
||||||
description: '分支同步失败',
|
description: '分支同步任务启动失败',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing((prev) => ({ ...prev, branches: false }));
|
setSyncing((prev) => ({ ...prev, branches: false }));
|
||||||
@ -370,6 +367,7 @@ const GitManager: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 选中仓库组
|
// 选中仓库组
|
||||||
const handleSelectGroup = (group: RepositoryGroupResponse) => {
|
const handleSelectGroup = (group: RepositoryGroupResponse) => {
|
||||||
setSelectedGroup(group);
|
setSelectedGroup(group);
|
||||||
@ -386,6 +384,7 @@ const GitManager: React.FC = () => {
|
|||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (time?: string) => {
|
const formatTime = (time?: string) => {
|
||||||
|
|
||||||
if (!time) return '-';
|
if (!time) return '-';
|
||||||
const diff = dayjs().diff(dayjs(time), 'day');
|
const diff = dayjs().diff(dayjs(time), 'day');
|
||||||
if (diff > 7) {
|
if (diff > 7) {
|
||||||
@ -394,6 +393,7 @@ const GitManager: React.FC = () => {
|
|||||||
return dayjs(time).fromNow();
|
return dayjs(time).fromNow();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 渲染可见性图标(带 Tooltip)
|
// 渲染可见性图标(带 Tooltip)
|
||||||
const renderVisibilityIcon = (visibility: string) => {
|
const renderVisibilityIcon = (visibility: string) => {
|
||||||
const config =
|
const config =
|
||||||
@ -421,6 +421,7 @@ const GitManager: React.FC = () => {
|
|||||||
return groups.filter((group) => {
|
return groups.filter((group) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
group.name.toLowerCase().includes(groupSearch.toLowerCase()) ||
|
group.name.toLowerCase().includes(groupSearch.toLowerCase()) ||
|
||||||
|
|
||||||
(group.path && group.path.toLowerCase().includes(groupSearch.toLowerCase()));
|
(group.path && group.path.toLowerCase().includes(groupSearch.toLowerCase()));
|
||||||
|
|
||||||
const filteredChildren = group.children
|
const filteredChildren = group.children
|
||||||
@ -511,10 +512,12 @@ const GitManager: React.FC = () => {
|
|||||||
|
|
||||||
// 过滤项目列表
|
// 过滤项目列表
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
|
|
||||||
if (!projectSearch) return projects;
|
if (!projectSearch) return projects;
|
||||||
return projects.filter(
|
return projects.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.name.toLowerCase().includes(projectSearch.toLowerCase()) ||
|
project.name.toLowerCase().includes(projectSearch.toLowerCase()) ||
|
||||||
|
|
||||||
(project.path && project.path.toLowerCase().includes(projectSearch.toLowerCase()))
|
(project.path && project.path.toLowerCase().includes(projectSearch.toLowerCase()))
|
||||||
);
|
);
|
||||||
}, [projects, projectSearch]);
|
}, [projects, projectSearch]);
|
||||||
@ -533,9 +536,9 @@ const GitManager: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
if (selectedInstanceId) {
|
if (selectedInstanceId) {
|
||||||
loadGroupTree();
|
loadGroupTree();
|
||||||
loadStatistics();
|
|
||||||
setSelectedGroup(undefined);
|
setSelectedGroup(undefined);
|
||||||
setSelectedProject(undefined);
|
setSelectedProject(undefined);
|
||||||
setProjects([]);
|
setProjects([]);
|
||||||
@ -546,25 +549,31 @@ const GitManager: React.FC = () => {
|
|||||||
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, groupSearch]);
|
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, groupSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-col h-full p-6 space-y-4">
|
<div className="flex flex-col h-full p-6 gap-4">
|
||||||
{/* 顶部标题栏 */}
|
{/* 顶部标题栏 */}
|
||||||
<div className="flex items-center justify-between flex-shrink-0">
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Git 仓库管理</h1>
|
<h1 className="text-3xl font-bold">Git 仓库管理</h1>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
管理和浏览 Git 仓库、项目和分支
|
管理和浏览 Git 仓库、项目和分支
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Select
|
<Select
|
||||||
|
|
||||||
value={selectedInstanceId?.toString()}
|
value={selectedInstanceId?.toString()}
|
||||||
onValueChange={(value) => setSelectedInstanceId(Number(value))}
|
onValueChange={(value) => setSelectedInstanceId(Number(value))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="w-[200px]">
|
||||||
|
|
||||||
<SelectValue placeholder="选择GIT实例" />
|
<SelectValue placeholder="选择GIT实例" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => (
|
||||||
<SelectItem key={instance.id} value={instance.id.toString()}>
|
<SelectItem key={instance.id} value={instance.id.toString()}>
|
||||||
{instance.name}
|
{instance.name}
|
||||||
@ -572,6 +581,7 @@ const GitManager: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSyncAll}
|
onClick={handleSyncAll}
|
||||||
disabled={syncing.all || !selectedInstanceId}
|
disabled={syncing.all || !selectedInstanceId}
|
||||||
@ -587,39 +597,6 @@ const GitManager: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
{statistics && (
|
|
||||||
<div className="grid grid-cols-3 gap-4 flex-shrink-0">
|
|
||||||
<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>
|
|
||||||
<FolderGit2 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{statistics.totalGroups}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="border-l-4 border-l-purple-500">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">项目</CardTitle>
|
|
||||||
<Code2 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{statistics.totalProjects}</div>
|
|
||||||
</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>
|
|
||||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{statistics.totalBranches}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 三栏布局 */}
|
{/* 三栏布局 */}
|
||||||
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden">
|
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden">
|
||||||
{/* 左栏:仓库组树 */}
|
{/* 左栏:仓库组树 */}
|
||||||
@ -630,6 +607,7 @@ const GitManager: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSyncGroups}
|
onClick={handleSyncGroups}
|
||||||
disabled={syncing.groups || !selectedInstanceId}
|
disabled={syncing.groups || !selectedInstanceId}
|
||||||
@ -639,15 +617,19 @@ const GitManager: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
||||||
placeholder="搜索仓库组..."
|
placeholder="搜索仓库组..."
|
||||||
value={groupSearch}
|
value={groupSearch}
|
||||||
onChange={(e) => setGroupSearch(e.target.value)}
|
onChange={(e) => setGroupSearch(e.target.value)}
|
||||||
|
|
||||||
className="pl-8 h-8"
|
className="pl-8 h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
@ -664,9 +646,11 @@ const GitManager: React.FC = () => {
|
|||||||
filteredGroupTree.map((group) => renderGroupTreeNode(group))
|
filteredGroupTree.map((group) => renderGroupTreeNode(group))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
{/* 中栏:项目列表 */}
|
{/* 中栏:项目列表 */}
|
||||||
<Card className="col-span-5 flex flex-col min-h-0">
|
<Card className="col-span-5 flex flex-col min-h-0">
|
||||||
<CardHeader className="border-b flex-shrink-0">
|
<CardHeader className="border-b flex-shrink-0">
|
||||||
@ -676,6 +660,7 @@ const GitManager: React.FC = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSyncProjects}
|
onClick={handleSyncProjects}
|
||||||
@ -686,34 +671,42 @@ const GitManager: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
||||||
placeholder="搜索项目..."
|
placeholder="搜索项目..."
|
||||||
value={projectSearch}
|
value={projectSearch}
|
||||||
onChange={(e) => setProjectSearch(e.target.value)}
|
onChange={(e) => setProjectSearch(e.target.value)}
|
||||||
|
|
||||||
className="pl-8 h-8"
|
className="pl-8 h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading.projects ? (
|
{loading.projects ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
) : !selectedGroup ? (
|
) : !selectedGroup ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
<Code2 className="h-12 w-12 mb-2 opacity-20" />
|
<Code2 className="h-12 w-12 mb-2 opacity-20" />
|
||||||
<p className="text-sm">请先选择仓库组</p>
|
<p className="text-sm">请先选择仓库组</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredProjects.length === 0 ? (
|
) : filteredProjects.length === 0 ? (
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
<Code2 className="h-12 w-12 mb-2 opacity-20" />
|
<Code2 className="h-12 w-12 mb-2 opacity-20" />
|
||||||
<p className="text-sm">暂无项目</p>
|
<p className="text-sm">暂无项目</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
{filteredProjects.map((project) => (
|
{filteredProjects.map((project) => (
|
||||||
|
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
|
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
|
||||||
@ -732,6 +725,7 @@ const GitManager: React.FC = () => {
|
|||||||
{project.name}
|
{project.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
{project.description}
|
{project.description}
|
||||||
@ -742,23 +736,27 @@ const GitManager: React.FC = () => {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<GitBranch className="h-3 w-3" />
|
<GitBranch className="h-3 w-3" />
|
||||||
{project.isDefaultBranch}
|
{project.isDefaultBranch}
|
||||||
|
|
||||||
</span>
|
</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" />
|
||||||
{formatTime(project.lastActivityAt)}
|
{formatTime(project.lastActivityAt)}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{project.webUrl && (
|
{project.webUrl && (
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<a
|
<a
|
||||||
href={project.webUrl}
|
href={project.webUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
@ -792,7 +790,7 @@ const GitManager: React.FC = () => {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={handleSyncBranches}
|
onClick={handleSyncBranches}
|
||||||
disabled={syncing.branches || !selectedProject}
|
disabled={syncing.branches || !selectedGroup}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`h-4 w-4 ${syncing.branches ? 'animate-spin' : ''}`}
|
className={`h-4 w-4 ${syncing.branches ? 'animate-spin' : ''}`}
|
||||||
@ -897,6 +895,7 @@ const GitManager: React.FC = () => {
|
|||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>在 GitLab 中查看</p>
|
<p>在 GitLab 中查看</p>
|
||||||
@ -912,8 +911,10 @@ const GitManager: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GitManager;
|
export default GitManager;
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type {
|
|||||||
RepositoryProjectQuery,
|
RepositoryProjectQuery,
|
||||||
RepositoryBranchResponse,
|
RepositoryBranchResponse,
|
||||||
RepositoryBranchQuery,
|
RepositoryBranchQuery,
|
||||||
GitStatistics,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { getExternalSystems } from '@/pages/Deploy/External/service';
|
import { getExternalSystems } from '@/pages/Deploy/External/service';
|
||||||
import { SystemType } from '@/pages/Deploy/External/types';
|
import { SystemType } from '@/pages/Deploy/External/types';
|
||||||
@ -61,6 +60,8 @@ export const getProjectsByGroupId = (repoGroupId: number) =>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步仓库项目数据
|
* 同步仓库项目数据
|
||||||
|
* @param externalSystemId 外部系统ID(必填)
|
||||||
|
* @param repoGroupId 仓库组ID(可选)不传=全量同步,传值=只同步该组
|
||||||
*/
|
*/
|
||||||
export const syncRepositoryProjects = (externalSystemId: number, repoGroupId?: number) =>
|
export const syncRepositoryProjects = (externalSystemId: number, repoGroupId?: number) =>
|
||||||
request.post<void>(`${PROJECT_URL}/sync`, null, {
|
request.post<void>(`${PROJECT_URL}/sync`, null, {
|
||||||
@ -85,6 +86,9 @@ export const getBranchesByProjectId = (repoProjectId: number) =>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步仓库分支数据
|
* 同步仓库分支数据
|
||||||
|
* @param externalSystemId 外部系统ID(必填)
|
||||||
|
* @param repoProjectId 项目ID(可选)不传=按repoGroupId规则,传值=只同步该项目(传此参数时repoGroupId必传)
|
||||||
|
* @param repoGroupId 仓库组ID(可选)不传=全量同步,传值=同步该组所有项目的分支
|
||||||
*/
|
*/
|
||||||
export const syncRepositoryBranches = (
|
export const syncRepositoryBranches = (
|
||||||
externalSystemId: number,
|
externalSystemId: number,
|
||||||
@ -95,16 +99,6 @@ export const syncRepositoryBranches = (
|
|||||||
params: { externalSystemId, repoProjectId, repoGroupId },
|
params: { externalSystemId, repoProjectId, repoGroupId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== 统计信息 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取Git仓库统计信息
|
|
||||||
*/
|
|
||||||
export const getGitStatistics = (externalSystemId: number) =>
|
|
||||||
request.get<GitStatistics>(`${GROUP_URL}/statistics`, {
|
|
||||||
params: { externalSystemId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==================== 批量操作 ====================
|
// ==================== 批量操作 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -136,16 +136,3 @@ export interface RepositoryBranchQuery {
|
|||||||
isDefaultBranch?: boolean;
|
isDefaultBranch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 统计信息
|
|
||||||
*/
|
|
||||||
export interface GitStatistics {
|
|
||||||
/** 总仓库组数 */
|
|
||||||
totalGroups: number;
|
|
||||||
/** 总项目数 */
|
|
||||||
totalProjects: number;
|
|
||||||
/** 总分支数 */
|
|
||||||
totalBranches: number;
|
|
||||||
/** 最后同步时间 */
|
|
||||||
lastSyncTime?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,561 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
FolderKanban,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import DynamicIcon from '@/components/DynamicIcon';
|
||||||
|
import LucideIconSelect from '@/components/LucideIconSelect';
|
||||||
|
import {
|
||||||
|
getJobCategories,
|
||||||
|
createJobCategory,
|
||||||
|
updateJobCategory,
|
||||||
|
deleteJobCategory
|
||||||
|
} from '../service';
|
||||||
|
import type {
|
||||||
|
JobCategoryResponse,
|
||||||
|
JobCategoryRequest,
|
||||||
|
JobCategoryQuery
|
||||||
|
} from '../types';
|
||||||
|
import type { Page } from '@/types/base';
|
||||||
|
|
||||||
|
interface CategoryManageDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryManageDialog: React.FC<CategoryManageDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Page<JobCategoryResponse> | null>(null);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [editRecord, setEditRecord] = useState<JobCategoryResponse | null>(null);
|
||||||
|
const [iconSelectOpen, setIconSelectOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteRecord, setDeleteRecord] = useState<JobCategoryResponse | null>(null);
|
||||||
|
|
||||||
|
// 分页状态
|
||||||
|
const [pageNum, setPageNum] = useState(DEFAULT_CURRENT - 1);
|
||||||
|
const [pageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
|
const form = useForm<JobCategoryRequest>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
color: '',
|
||||||
|
sort: 0,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载分类列表
|
||||||
|
const loadCategories = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const query: JobCategoryQuery = {
|
||||||
|
pageNum,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
if (searchText) {
|
||||||
|
query.name = searchText;
|
||||||
|
}
|
||||||
|
const result = await getJobCategories(query);
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载失败',
|
||||||
|
description: '加载分类列表失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
}, [open, searchText, pageNum, pageSize]);
|
||||||
|
|
||||||
|
// 搜索时重置页码
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchText !== '') {
|
||||||
|
setPageNum(0);
|
||||||
|
}
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
// 新建
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditRecord(null);
|
||||||
|
form.reset({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
color: '',
|
||||||
|
sort: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
setEditMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (record: JobCategoryResponse) => {
|
||||||
|
setEditRecord(record);
|
||||||
|
form.reset({
|
||||||
|
name: record.name,
|
||||||
|
code: record.code,
|
||||||
|
description: record.description || '',
|
||||||
|
icon: record.icon || '',
|
||||||
|
color: record.color || '',
|
||||||
|
sort: record.sort,
|
||||||
|
enabled: record.enabled,
|
||||||
|
});
|
||||||
|
setEditMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开删除确认对话框
|
||||||
|
const handleDeleteClick = (record: JobCategoryResponse) => {
|
||||||
|
setDeleteRecord(record);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteRecord) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteJobCategory(deleteRecord.id);
|
||||||
|
toast({
|
||||||
|
title: '删除成功',
|
||||||
|
description: `分类 "${deleteRecord.name}" 已删除`,
|
||||||
|
});
|
||||||
|
loadCategories();
|
||||||
|
onSuccess?.();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteRecord(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '删除失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
const handleSave = async (values: JobCategoryRequest) => {
|
||||||
|
try {
|
||||||
|
if (editRecord) {
|
||||||
|
await updateJobCategory(editRecord.id, values);
|
||||||
|
toast({
|
||||||
|
title: '更新成功',
|
||||||
|
description: `分类 "${values.name}" 已更新`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createJobCategory(values);
|
||||||
|
toast({
|
||||||
|
title: '创建成功',
|
||||||
|
description: `分类 "${values.name}" 已创建`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setEditMode(false);
|
||||||
|
loadCategories();
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '保存失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditMode(false);
|
||||||
|
setEditRecord(null);
|
||||||
|
form.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FolderKanban className="h-5 w-5" />
|
||||||
|
分类管理
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!editMode ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 搜索和新建 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索分类名称..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建分类
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类列表 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table minWidth="770px">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead width="160px">分类名称</TableHead>
|
||||||
|
<TableHead width="140px">分类代码</TableHead>
|
||||||
|
<TableHead width="60px">图标</TableHead>
|
||||||
|
<TableHead width="80px">颜色</TableHead>
|
||||||
|
<TableHead width="80px">排序</TableHead>
|
||||||
|
<TableHead width="80px">状态</TableHead>
|
||||||
|
<TableHead width="150px">描述</TableHead>
|
||||||
|
<TableHead width="100px" sticky>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center h-32">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data && data.content.length > 0 ? (
|
||||||
|
data.content.map((record) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell className="font-medium">{record.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">{record.code}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.icon ? (
|
||||||
|
<DynamicIcon name={record.icon} className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.color ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded border"
|
||||||
|
style={{ backgroundColor: record.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{record.color}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{record.sort}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.enabled ? (
|
||||||
|
<Badge variant="default" className="gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
启用
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
禁用
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[150px] truncate" title={record.description}>
|
||||||
|
{record.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sticky width="100px">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDeleteClick(record)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center h-32 text-muted-foreground">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{data && data.content.length > 0 && (
|
||||||
|
<DataTablePagination
|
||||||
|
pageIndex={pageNum}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
totalElements={data.totalElements}
|
||||||
|
onPageChange={setPageNum}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleSave)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
rules={{ required: '请输入分类名称' }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>分类名称</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="请输入分类名称" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
rules={{ required: '请输入分类代码' }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>分类代码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="请输入分类代码" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="icon"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>图标</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input {...field} placeholder="选择图标" readOnly />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIconSelectOpen(true)}
|
||||||
|
>
|
||||||
|
{field.value ? (
|
||||||
|
<DynamicIcon name={field.value} className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
'选择'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="color"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>颜色</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input {...field} placeholder="#000000" />
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={field.value || '#000000'}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded border cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>排序号</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入排序号"
|
||||||
|
onChange={e => field.onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>是否启用</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>描述</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder="请输入描述" rows={3} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 图标选择器 */}
|
||||||
|
<LucideIconSelect
|
||||||
|
open={iconSelectOpen}
|
||||||
|
onOpenChange={setIconSelectOpen}
|
||||||
|
value={form.watch('icon')}
|
||||||
|
onValueChange={(value) => form.setValue('icon', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除分类 <span className="font-semibold">{deleteRecord?.name}</span> 吗?
|
||||||
|
此操作无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete}>确认删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryManageDialog;
|
||||||
|
|
||||||
@ -0,0 +1,327 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
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 {
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Server,
|
||||||
|
Calendar,
|
||||||
|
AlertCircle,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
interface JobLogDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
job: ScheduleJobResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JobLogDialog: React.FC<JobLogDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
job
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Page<ScheduleJobLogResponse> | null>(null);
|
||||||
|
const [selectedLog, setSelectedLog] = useState<ScheduleJobLogResponse | null>(null);
|
||||||
|
const [query, setQuery] = useState<ScheduleJobLogQuery>({
|
||||||
|
pageNum: DEFAULT_CURRENT - 1,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
jobId: undefined,
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载日志列表
|
||||||
|
const loadLogs = async () => {
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getJobLogs({
|
||||||
|
...query,
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载日志失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载失败',
|
||||||
|
description: '加载执行日志失败',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && job) {
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
}, [open, job, query]);
|
||||||
|
|
||||||
|
// 重置查询条件
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && job) {
|
||||||
|
setQuery({
|
||||||
|
pageNum: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
jobId: job.id,
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
setSelectedLog(null);
|
||||||
|
}
|
||||||
|
}, [open, job]);
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
const getStatusBadge = (status: LogStatus) => {
|
||||||
|
const statusMap: Record<LogStatus, {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive';
|
||||||
|
text: string;
|
||||||
|
icon: React.ElementType
|
||||||
|
}> = {
|
||||||
|
SUCCESS: { variant: 'default', text: '成功', icon: CheckCircle2 },
|
||||||
|
FAIL: { variant: 'destructive', text: '失败', icon: XCircle },
|
||||||
|
TIMEOUT: { variant: 'secondary', text: '超时', icon: Clock },
|
||||||
|
};
|
||||||
|
const statusInfo = statusMap[status];
|
||||||
|
const Icon = statusInfo.icon;
|
||||||
|
return (
|
||||||
|
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{statusInfo.text}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化耗时
|
||||||
|
const formatDuration = (duration?: number) => {
|
||||||
|
if (!duration) return '-';
|
||||||
|
if (duration < 1000) return `${duration}ms`;
|
||||||
|
if (duration < 60000) return `${(duration / 1000).toFixed(2)}s`;
|
||||||
|
return `${(duration / 60000).toFixed(2)}min`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
执行日志 - {job?.jobName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col space-y-4">
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select
|
||||||
|
value={query.status || ''}
|
||||||
|
onValueChange={(value) => setQuery({ ...query, status: value as LogStatus || undefined, pageNum: 0 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">全部状态</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" />
|
||||||
|
刷新
|
||||||
|
</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>
|
||||||
|
) : data && data.content.length > 0 ? (
|
||||||
|
data.content.map((log) => (
|
||||||
|
<TableRow
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</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 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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">服务器IP:</span>
|
||||||
|
<span className="ml-2">{selectedLog.serverIp || '-'}</span>
|
||||||
|
</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>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JobLogDialog;
|
||||||
|
|
||||||
458
frontend/src/pages/Deploy/ScheduleJob/List/index.tsx
Normal file
458
frontend/src/pages/Deploy/ScheduleJob/List/index.tsx
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
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 {
|
||||||
|
Loader2, Plus, Search, Edit, Trash2, Play, Pause,
|
||||||
|
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { getScheduleJobs, getJobCategoryList, executeJobNow, pauseJob, resumeJob } 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 dayjs from 'dayjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务列表页
|
||||||
|
*/
|
||||||
|
const ScheduleJobList: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Page<ScheduleJobResponse> | null>(null);
|
||||||
|
const [categories, setCategories] = useState<JobCategoryResponse[]>([]);
|
||||||
|
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||||
|
const [logDialogOpen, setLogDialogOpen] = useState(false);
|
||||||
|
const [selectedJob, setSelectedJob] = useState<ScheduleJobResponse | null>(null);
|
||||||
|
const [query, setQuery] = useState<ScheduleJobQuery>({
|
||||||
|
pageNum: DEFAULT_CURRENT - 1,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
jobName: '',
|
||||||
|
categoryId: undefined,
|
||||||
|
status: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getScheduleJobs(query);
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载定时任务失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载分类
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getJobCategoryList();
|
||||||
|
setCategories(result || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setQuery(prev => ({ ...prev, pageNum: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
setQuery({
|
||||||
|
pageNum: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
jobName: '',
|
||||||
|
categoryId: undefined,
|
||||||
|
status: undefined
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即执行
|
||||||
|
const handleExecute = async (record: ScheduleJobResponse) => {
|
||||||
|
try {
|
||||||
|
await executeJobNow(record.id);
|
||||||
|
toast({
|
||||||
|
title: '执行成功',
|
||||||
|
description: `任务 "${record.jobName}" 已开始执行`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('执行失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '执行失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暂停任务
|
||||||
|
const handlePause = async (record: ScheduleJobResponse) => {
|
||||||
|
try {
|
||||||
|
await pauseJob(record.id);
|
||||||
|
toast({
|
||||||
|
title: '暂停成功',
|
||||||
|
description: `任务 "${record.jobName}" 已暂停`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('暂停失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '暂停失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 恢复任务
|
||||||
|
const handleResume = async (record: ScheduleJobResponse) => {
|
||||||
|
try {
|
||||||
|
await resumeJob(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);
|
||||||
|
setLogDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
const getStatusBadge = (status: JobStatus) => {
|
||||||
|
const statusMap: Record<JobStatus, {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
text: string;
|
||||||
|
icon: React.ElementType
|
||||||
|
}> = {
|
||||||
|
ENABLED: { variant: 'default', text: '启用', icon: CheckCircle2 },
|
||||||
|
DISABLED: { variant: 'secondary', text: '禁用', icon: XCircle },
|
||||||
|
PAUSED: { variant: 'outline', text: '暂停', icon: Pause },
|
||||||
|
};
|
||||||
|
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
|
||||||
|
const Icon = statusInfo.icon;
|
||||||
|
return (
|
||||||
|
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{statusInfo.text}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = data?.totalElements || 0;
|
||||||
|
const enabledCount = data?.content?.filter(d => d.status === 'ENABLED').length || 0;
|
||||||
|
const pausedCount = data?.content?.filter(d => d.status === 'PAUSED').length || 0;
|
||||||
|
return { total, enabledCount, pausedCount };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">定时任务管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
创建和管理定时任务,配置执行计划和参数。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<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>
|
||||||
|
<Activity className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.total}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">全部定时任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-green-500/10 to-green-500/5 border-green-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-green-700">运行中</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.enabledCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">正在运行的任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-yellow-500/10 to-yellow-500/5 border-yellow-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-yellow-700">已暂停</CardTitle>
|
||||||
|
<Pause className="h-4 w-4 text-yellow-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.pausedCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">暂停执行的任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>任务列表</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setCategoryDialogOpen(true)}>
|
||||||
|
<FolderKanban className="h-4 w-4 mr-2" />
|
||||||
|
分类管理
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => toast({ title: '功能开发中' })}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索任务名称..."
|
||||||
|
value={query.jobName}
|
||||||
|
onChange={(e) => setQuery({ ...query, jobName: e.target.value })}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={query.categoryId?.toString()}
|
||||||
|
onValueChange={(value) => setQuery({ ...query, categoryId: value ? Number(value) : undefined, pageNum: 0 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="全部分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">全部分类</SelectItem>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id.toString()}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={query.status || ''}
|
||||||
|
onValueChange={(value) => setQuery({ ...query, status: value as JobStatus || undefined, pageNum: 0 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">全部状态</SelectItem>
|
||||||
|
<SelectItem value="ENABLED">启用</SelectItem>
|
||||||
|
<SelectItem value="DISABLED">禁用</SelectItem>
|
||||||
|
<SelectItem value="PAUSED">暂停</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={handleSearch}>
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务列表 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table minWidth="1600px">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead width="180px">任务名称</TableHead>
|
||||||
|
<TableHead width="120px">任务分类</TableHead>
|
||||||
|
<TableHead width="150px">Cron表达式</TableHead>
|
||||||
|
<TableHead width="120px">Bean名称</TableHead>
|
||||||
|
<TableHead width="120px">方法名称</TableHead>
|
||||||
|
<TableHead width="100px">状态</TableHead>
|
||||||
|
<TableHead width="160px">上次执行</TableHead>
|
||||||
|
<TableHead width="160px">下次执行</TableHead>
|
||||||
|
<TableHead width="100px">执行次数</TableHead>
|
||||||
|
<TableHead width="90px">成功率</TableHead>
|
||||||
|
<TableHead width="240px" sticky>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={11} className="h-24 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data && data.content.length > 0 ? (
|
||||||
|
data.content.map((record) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell className="font-medium">{record.jobName}</TableCell>
|
||||||
|
<TableCell>{record.category?.name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">{record.cronExpression}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs">{record.beanName}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-xs">{record.methodName}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(record.status)}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{record.lastExecuteTime ? dayjs(record.lastExecuteTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{record.nextExecuteTime ? dayjs(record.nextExecuteTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="outline">{record.executeCount || 0}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.executeCount && record.executeCount > 0 ? (
|
||||||
|
<Badge variant={
|
||||||
|
(record.successCount! / record.executeCount) >= 0.9 ? 'default' :
|
||||||
|
(record.successCount! / record.executeCount) >= 0.7 ? 'outline' : 'destructive'
|
||||||
|
}>
|
||||||
|
{((record.successCount! / record.executeCount) * 100).toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sticky width="240px">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleExecute(record)}
|
||||||
|
title="立即执行"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{record.status === 'ENABLED' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handlePause(record)}
|
||||||
|
title="暂停"
|
||||||
|
>
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{record.status === 'PAUSED' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleResume(record)}
|
||||||
|
title="恢复"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => handleViewLog(record)}
|
||||||
|
title="查看日志"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => toast({ title: '功能开发中' })}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => toast({ title: '功能开发中' })}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={11} className="h-24 text-center text-muted-foreground">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{data && data.content.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<DataTablePagination
|
||||||
|
pageIndex={query.pageNum || 0}
|
||||||
|
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
|
||||||
|
pageCount={data.totalPages}
|
||||||
|
onPageChange={(pageIndex) => setQuery({ ...query, pageNum: pageIndex })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 分类管理对话框 */}
|
||||||
|
<CategoryManageDialog
|
||||||
|
open={categoryDialogOpen}
|
||||||
|
onOpenChange={setCategoryDialogOpen}
|
||||||
|
onSuccess={loadCategories}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 任务日志对话框 */}
|
||||||
|
<JobLogDialog
|
||||||
|
open={logDialogOpen}
|
||||||
|
onOpenChange={setLogDialogOpen}
|
||||||
|
job={selectedJob}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduleJobList;
|
||||||
|
|
||||||
119
frontend/src/pages/Deploy/ScheduleJob/List/service.ts
Normal file
119
frontend/src/pages/Deploy/ScheduleJob/List/service.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { Page } from '@/types/base';
|
||||||
|
import type {
|
||||||
|
JobCategoryResponse,
|
||||||
|
JobCategoryRequest,
|
||||||
|
JobCategoryQuery,
|
||||||
|
ScheduleJobResponse,
|
||||||
|
ScheduleJobRequest,
|
||||||
|
ScheduleJobQuery,
|
||||||
|
ScheduleJobLogResponse,
|
||||||
|
ScheduleJobLogQuery,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const JOB_CATEGORY_URL = '/api/v1/schedule/job-categories';
|
||||||
|
const SCHEDULE_JOB_URL = '/api/v1/schedule/jobs';
|
||||||
|
const JOB_LOG_URL = '/api/v1/schedule/job-logs';
|
||||||
|
|
||||||
|
// ==================== 任务分类 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务分类列表
|
||||||
|
*/
|
||||||
|
export const getJobCategoryList = (params?: JobCategoryQuery) =>
|
||||||
|
request.get<JobCategoryResponse[]>(`${JOB_CATEGORY_URL}/list`, { params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务分类分页
|
||||||
|
*/
|
||||||
|
export const getJobCategories = (params?: JobCategoryQuery) =>
|
||||||
|
request.get<Page<JobCategoryResponse>>(`${JOB_CATEGORY_URL}/page`, { params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建任务分类
|
||||||
|
*/
|
||||||
|
export const createJobCategory = (data: JobCategoryRequest) =>
|
||||||
|
request.post<JobCategoryResponse>(JOB_CATEGORY_URL, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新任务分类
|
||||||
|
*/
|
||||||
|
export const updateJobCategory = (id: number, data: JobCategoryRequest) =>
|
||||||
|
request.put<JobCategoryResponse>(`${JOB_CATEGORY_URL}/${id}`, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务分类
|
||||||
|
*/
|
||||||
|
export const deleteJobCategory = (id: number) =>
|
||||||
|
request.delete(`${JOB_CATEGORY_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除任务分类
|
||||||
|
*/
|
||||||
|
export const batchDeleteJobCategories = (ids: number[]) =>
|
||||||
|
request.post(`${JOB_CATEGORY_URL}/batch-delete`, { ids });
|
||||||
|
|
||||||
|
// ==================== 定时任务 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取定时任务分页
|
||||||
|
*/
|
||||||
|
export const getScheduleJobs = (params?: ScheduleJobQuery) =>
|
||||||
|
request.get<Page<ScheduleJobResponse>>(`${SCHEDULE_JOB_URL}/page`, { params });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取定时任务详情
|
||||||
|
*/
|
||||||
|
export const getScheduleJob = (id: number) =>
|
||||||
|
request.get<ScheduleJobResponse>(`${SCHEDULE_JOB_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建定时任务
|
||||||
|
*/
|
||||||
|
export const createScheduleJob = (data: ScheduleJobRequest) =>
|
||||||
|
request.post<ScheduleJobResponse>(SCHEDULE_JOB_URL, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新定时任务
|
||||||
|
*/
|
||||||
|
export const updateScheduleJob = (id: number, data: ScheduleJobRequest) =>
|
||||||
|
request.put<ScheduleJobResponse>(`${SCHEDULE_JOB_URL}/${id}`, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除定时任务
|
||||||
|
*/
|
||||||
|
export const deleteScheduleJob = (id: number) =>
|
||||||
|
request.delete(`${SCHEDULE_JOB_URL}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除定时任务
|
||||||
|
*/
|
||||||
|
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 pauseJob = (id: number) =>
|
||||||
|
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/pause`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复任务
|
||||||
|
*/
|
||||||
|
export const resumeJob = (id: number) =>
|
||||||
|
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/resume`);
|
||||||
|
|
||||||
|
// ==================== 任务日志 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务执行日志分页
|
||||||
|
*/
|
||||||
|
export const getJobLogs = (params?: ScheduleJobLogQuery) =>
|
||||||
|
request.get<Page<ScheduleJobLogResponse>>(`${JOB_LOG_URL}/page`, { params });
|
||||||
|
|
||||||
150
frontend/src/pages/Deploy/ScheduleJob/List/types.ts
Normal file
150
frontend/src/pages/Deploy/ScheduleJob/List/types.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import type { BaseResponse, BaseRequest, BaseQuery } from '@/types/base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务分类响应
|
||||||
|
*/
|
||||||
|
export interface JobCategoryResponse extends BaseResponse {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务分类请求
|
||||||
|
*/
|
||||||
|
export interface JobCategoryRequest extends BaseRequest {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务分类查询
|
||||||
|
*/
|
||||||
|
export interface JobCategoryQuery extends BaseQuery {
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务状态
|
||||||
|
*/
|
||||||
|
export type JobStatus = 'ENABLED' | 'DISABLED' | 'PAUSED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务响应
|
||||||
|
*/
|
||||||
|
export interface ScheduleJobResponse extends BaseResponse {
|
||||||
|
jobName: string;
|
||||||
|
jobDescription?: string;
|
||||||
|
categoryId: number;
|
||||||
|
category?: JobCategoryResponse;
|
||||||
|
|
||||||
|
// 执行配置
|
||||||
|
beanName: string;
|
||||||
|
methodName: string;
|
||||||
|
formDefinitionId?: number;
|
||||||
|
methodParams?: string;
|
||||||
|
|
||||||
|
// 调度配置
|
||||||
|
cronExpression: string;
|
||||||
|
status: JobStatus;
|
||||||
|
concurrent?: boolean;
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
lastExecuteTime?: string;
|
||||||
|
nextExecuteTime?: string;
|
||||||
|
executeCount?: number;
|
||||||
|
successCount?: number;
|
||||||
|
failCount?: number;
|
||||||
|
|
||||||
|
// 高级配置
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
retryCount?: number;
|
||||||
|
alertEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务请求
|
||||||
|
*/
|
||||||
|
export interface ScheduleJobRequest extends BaseRequest {
|
||||||
|
jobName: string;
|
||||||
|
jobDescription?: string;
|
||||||
|
categoryId: number;
|
||||||
|
|
||||||
|
// 执行配置
|
||||||
|
beanName: string;
|
||||||
|
methodName: string;
|
||||||
|
formDefinitionId?: number;
|
||||||
|
methodParams?: string;
|
||||||
|
|
||||||
|
// 调度配置
|
||||||
|
cronExpression: string;
|
||||||
|
status: JobStatus;
|
||||||
|
concurrent?: boolean;
|
||||||
|
|
||||||
|
// 高级配置
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
retryCount?: number;
|
||||||
|
alertEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务查询
|
||||||
|
*/
|
||||||
|
export interface ScheduleJobQuery extends BaseQuery {
|
||||||
|
jobName?: string;
|
||||||
|
categoryId?: number;
|
||||||
|
status?: JobStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行日志状态
|
||||||
|
*/
|
||||||
|
export type LogStatus = 'SUCCESS' | 'FAIL' | 'TIMEOUT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务执行日志响应
|
||||||
|
*/
|
||||||
|
export interface ScheduleJobLogResponse extends BaseResponse {
|
||||||
|
jobId: number;
|
||||||
|
jobName: string;
|
||||||
|
|
||||||
|
// 执行信息
|
||||||
|
beanName: string;
|
||||||
|
methodName: string;
|
||||||
|
methodParams?: string;
|
||||||
|
|
||||||
|
// 时间统计
|
||||||
|
executeTime: string;
|
||||||
|
finishTime?: string;
|
||||||
|
duration?: number;
|
||||||
|
|
||||||
|
// 状态信息
|
||||||
|
status: LogStatus;
|
||||||
|
resultMessage?: string;
|
||||||
|
exceptionInfo?: string;
|
||||||
|
|
||||||
|
// 服务器信息
|
||||||
|
serverIp?: string;
|
||||||
|
serverHost?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时任务执行日志查询
|
||||||
|
*/
|
||||||
|
export interface ScheduleJobLogQuery extends BaseQuery {
|
||||||
|
jobId?: number;
|
||||||
|
jobName?: string;
|
||||||
|
status?: LogStatus;
|
||||||
|
executeTimeStart?: string;
|
||||||
|
executeTimeEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,653 +0,0 @@
|
|||||||
import React, {useState, useEffect} from 'react';
|
|
||||||
import {PageContainer} from '@ant-design/pro-components';
|
|
||||||
import {Button, Card, Form, Input, InputNumber, Select, Switch, Tabs, Row, Col, message, ColorPicker} from 'antd';
|
|
||||||
import type {NodeDesignDataResponse} from './types';
|
|
||||||
import * as service from './service';
|
|
||||||
import {useParams} from 'react-router-dom';
|
|
||||||
|
|
||||||
// Tab 配置
|
|
||||||
const TAB_CONFIG = [
|
|
||||||
{key: 'panel', label: '属性(预览)', schemaKey: 'panelVariablesSchema', readonly: true},
|
|
||||||
{key: 'local', label: '环境变量(预览)', schemaKey: 'localVariablesSchema', readonly: true},
|
|
||||||
{key: 'form', label: '表单(预览)', schemaKey: 'formVariablesSchema', readonly: true},
|
|
||||||
{key: 'ui', label: 'UI配置', schemaKey: 'uiVariables', readonly: false}
|
|
||||||
];
|
|
||||||
|
|
||||||
// 渲染具体的表单控件
|
|
||||||
const renderField = (schema: any, fieldPath: string) => {
|
|
||||||
const commonProps = {
|
|
||||||
placeholder: `请输入${schema.title}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 颜色字段路径列表
|
|
||||||
const colorFields = [
|
|
||||||
'ports.groups.in.attrs.circle.fill',
|
|
||||||
'ports.groups.in.attrs.circle.stroke',
|
|
||||||
'ports.groups.out.attrs.circle.fill',
|
|
||||||
'ports.groups.out.attrs.circle.stroke',
|
|
||||||
'style.fill',
|
|
||||||
'style.stroke',
|
|
||||||
'style.iconColor'
|
|
||||||
];
|
|
||||||
|
|
||||||
// 检查是否是颜色字段
|
|
||||||
if (colorFields.some(path => fieldPath.endsWith(path))) {
|
|
||||||
return <ColorPicker showText/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schema.enum) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
{...commonProps}
|
|
||||||
options={schema.enum.map((value: string, index: number) => ({
|
|
||||||
label: schema.enumNames?.[index] || value,
|
|
||||||
value
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (schema.type) {
|
|
||||||
case 'string':
|
|
||||||
return <Input {...commonProps} />;
|
|
||||||
case 'integer':
|
|
||||||
case 'number':
|
|
||||||
return <InputNumber {...commonProps} style={{width: '100%'}}/>;
|
|
||||||
case 'boolean':
|
|
||||||
return <Switch/>;
|
|
||||||
default:
|
|
||||||
return <Input {...commonProps} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 表单渲染器组件
|
|
||||||
const FormRenderer: React.FC<{
|
|
||||||
schema: any;
|
|
||||||
path?: string;
|
|
||||||
readOnly?: boolean;
|
|
||||||
}> = ({schema, path = '', readOnly = false}) => {
|
|
||||||
if (!schema || !schema.properties) return null;
|
|
||||||
const renderPortConfig = (portSchema: any, portPath: string) => {
|
|
||||||
if (!portSchema || !portSchema.properties) return null;
|
|
||||||
|
|
||||||
const {position, attrs} = portSchema.properties;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 先渲染端口位置 */}
|
|
||||||
<Form.Item
|
|
||||||
name={`${portPath}.position`}
|
|
||||||
label={
|
|
||||||
<span style={{fontSize: '14px'}}>
|
|
||||||
{position.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
tooltip={position.description}
|
|
||||||
required={portSchema.required?.includes('position')}
|
|
||||||
initialValue={'default' in position ? position.default : undefined}
|
|
||||||
style={{marginBottom: 16}}
|
|
||||||
>
|
|
||||||
{readOnly ? (
|
|
||||||
<span style={{color: '#666'}}>{position.default || '-'}</span>
|
|
||||||
) : (
|
|
||||||
renderField(position, `${portPath}.position`)
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 再渲染端口属性 */}
|
|
||||||
{attrs && (
|
|
||||||
<Card
|
|
||||||
title={attrs.title}
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
marginBottom: 16,
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #f0f0f0'
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
body: {padding: '16px'}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormRenderer
|
|
||||||
schema={attrs}
|
|
||||||
path={`${portPath}.attrs`}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{padding: '8px 0'}}>
|
|
||||||
{Object.entries(schema.properties).map(([key, value]: [string, any]) => {
|
|
||||||
const fieldPath = path ? `${path}.${key}` : key;
|
|
||||||
if (value.type === 'object') {
|
|
||||||
// 特殊处理端口组配置
|
|
||||||
if (key === 'groups' && path.endsWith('ports')) {
|
|
||||||
const inPort = value.properties?.in;
|
|
||||||
const outPort = value.properties?.out;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row key={fieldPath} gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Card
|
|
||||||
title={inPort.title}
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
marginBottom: 16,
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #f0f0f0'
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
body: {padding: '16px'}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderPortConfig(inPort, `${fieldPath}.in`)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Card
|
|
||||||
title={outPort.title}
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
marginBottom: 16,
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #f0f0f0'
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
body: {padding: '16px'}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderPortConfig(outPort, `${fieldPath}.out`)}
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={fieldPath}
|
|
||||||
title={
|
|
||||||
<span style={{fontSize: '14px', fontWeight: 500}}>
|
|
||||||
{value.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
marginBottom: 16,
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #f0f0f0'
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
body: {padding: '16px'}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormRenderer
|
|
||||||
schema={value}
|
|
||||||
path={fieldPath}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form.Item
|
|
||||||
key={fieldPath}
|
|
||||||
name={fieldPath}
|
|
||||||
label={
|
|
||||||
<span style={{fontSize: '14px'}}>
|
|
||||||
{value.title}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
tooltip={value.description}
|
|
||||||
required={schema.required?.includes(key)}
|
|
||||||
initialValue={'default' in value ? value.default : undefined}
|
|
||||||
style={{marginBottom: 16}}
|
|
||||||
>
|
|
||||||
{readOnly ? (
|
|
||||||
<span style={{color: '#666'}}>{value.default || '-'}</span>
|
|
||||||
) : (
|
|
||||||
renderField(value, fieldPath)
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NodeDesignForm: React.FC = () => {
|
|
||||||
const [nodeDefinitionsDefined, setNodeDefinitionsDefined] = useState<NodeDesignDataResponse[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedNode, setSelectedNode] = useState<NodeDesignDataResponse | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('panel');
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
|
||||||
const [editData, setEditData] = useState<NodeDesignDataResponse | null>(null);
|
|
||||||
|
|
||||||
// 从路由参数获取 id
|
|
||||||
const { id } = useParams();
|
|
||||||
|
|
||||||
// 加载节点定义数据
|
|
||||||
useEffect(() => {
|
|
||||||
const loadNodeDefinitions = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await service.getNodeDefinitionsDefined();
|
|
||||||
setNodeDefinitionsDefined(data);
|
|
||||||
// 自动选中第一个节点
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
handleNodeSelect(data[0]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载节点定义失败:', error);
|
|
||||||
message.error('加载节点定义失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadNodeDefinitions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 如果有 id,加载节点详情
|
|
||||||
useEffect(() => {
|
|
||||||
const loadNodeDetail = async () => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await service.getNodeDefinition(id);
|
|
||||||
if (data) {
|
|
||||||
setIsEdit(true);
|
|
||||||
setEditData(data);
|
|
||||||
// 设置基本信息表单值
|
|
||||||
form.setFieldsValue({
|
|
||||||
'base.nodeType': data.nodeType,
|
|
||||||
'base.nodeCode': data.nodeCode,
|
|
||||||
'base.nodeName': data.nodeName,
|
|
||||||
'base.category': data.category,
|
|
||||||
'base.description': data.description
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载节点详情失败:', error);
|
|
||||||
message.error('加载节点详情失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadNodeDetail();
|
|
||||||
}, [id, form]);
|
|
||||||
|
|
||||||
|
|
||||||
// 获取当前节点可用的 Tab 列表
|
|
||||||
const getAvailableTabs = (node: NodeDesignDataResponse | null) => {
|
|
||||||
if (!node) return [];
|
|
||||||
return TAB_CONFIG.filter(tab => {
|
|
||||||
const value = node[tab.schemaKey as keyof NodeDesignDataResponse];
|
|
||||||
return value !== null && value !== undefined;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理节点选择
|
|
||||||
const handleNodeSelect = (node: NodeDesignDataResponse) => {
|
|
||||||
console.log('选择节点:', node);
|
|
||||||
console.log('当前编辑状态:', isEdit);
|
|
||||||
console.log('当前编辑数据:', editData);
|
|
||||||
|
|
||||||
setSelectedNode(node);
|
|
||||||
// 更新表单数据
|
|
||||||
form.setFieldsValue({
|
|
||||||
'base.nodeType': node.nodeType,
|
|
||||||
'base.nodeCode': node.nodeCode,
|
|
||||||
'base.nodeName': node.nodeName,
|
|
||||||
'base.category': node.category,
|
|
||||||
'base.description': node.description
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理 Tab 切换
|
|
||||||
const handleTabChange = (key: string) => {
|
|
||||||
setActiveTab(key);
|
|
||||||
// 不需要重置表单
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理保存
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
console.log('Form values:', values);
|
|
||||||
|
|
||||||
// 提取基本信息字段
|
|
||||||
const baseFields = {
|
|
||||||
nodeType: values['base.nodeType'],
|
|
||||||
nodeCode: values['base.nodeCode'],
|
|
||||||
nodeName: values['base.nodeName'],
|
|
||||||
category: values['base.category'],
|
|
||||||
description: values['base.description']
|
|
||||||
};
|
|
||||||
|
|
||||||
// 移除基本信息字段,处理 UI 配置
|
|
||||||
const uiValues = {...values};
|
|
||||||
delete uiValues['base.nodeType'];
|
|
||||||
delete uiValues['base.nodeCode'];
|
|
||||||
delete uiValues['base.nodeName'];
|
|
||||||
delete uiValues['base.category'];
|
|
||||||
delete uiValues['base.description'];
|
|
||||||
|
|
||||||
// 处理颜色值转换为十六进制
|
|
||||||
const processColorValue = (value: any): any => {
|
|
||||||
if (!value || typeof value !== 'object') return value;
|
|
||||||
|
|
||||||
// 如果是 ColorPicker 的值,转换为十六进制
|
|
||||||
if (value.metaColor) {
|
|
||||||
const { r, g, b } = value.metaColor;
|
|
||||||
const toHex = (n: number): string => {
|
|
||||||
const hex = n.toString(16);
|
|
||||||
return hex.length === 1 ? '0' + hex : hex;
|
|
||||||
};
|
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是普通对象,递归处理
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
const result: any = {};
|
|
||||||
Object.keys(value).forEach(key => {
|
|
||||||
result[key] = processColorValue(value[key]);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 将扁平的键值对转换为嵌套对象
|
|
||||||
const convertToNestedObject = (flatObj: any) => {
|
|
||||||
const result: any = {};
|
|
||||||
|
|
||||||
Object.keys(flatObj).forEach(key => {
|
|
||||||
const parts = key.split('.');
|
|
||||||
let current = result;
|
|
||||||
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
current[parts[i]] = current[parts[i]] || {};
|
|
||||||
current = current[parts[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理颜色值
|
|
||||||
const value = processColorValue(flatObj[key]);
|
|
||||||
current[parts[parts.length - 1]] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveData = {
|
|
||||||
...selectedNode,
|
|
||||||
...baseFields,
|
|
||||||
uiVariables: convertToNestedObject(uiValues)
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Save data:', saveData);
|
|
||||||
|
|
||||||
// 根据是否是编辑模式调用不同的接口
|
|
||||||
if (isEdit && editData?.id) {
|
|
||||||
await service.updateNodeDefinition(editData.id, saveData);
|
|
||||||
message.success('更新成功');
|
|
||||||
} else {
|
|
||||||
await service.saveNodeDefinition(saveData);
|
|
||||||
message.success('保存成功');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存失败:', error);
|
|
||||||
message.error('保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取当前 schema
|
|
||||||
const getCurrentSchema = () => {
|
|
||||||
if (!selectedNode) return null;
|
|
||||||
const currentTab = TAB_CONFIG.find(tab => tab.key === activeTab);
|
|
||||||
if (!currentTab) return null;
|
|
||||||
|
|
||||||
const schema = selectedNode[currentTab.schemaKey as keyof NodeDesignDataResponse];
|
|
||||||
console.log('当前 schema:', schema);
|
|
||||||
console.log('是否编辑模式:', isEdit);
|
|
||||||
console.log('编辑数据:', editData);
|
|
||||||
|
|
||||||
// 如果是编辑模式且有保存的数据,合并到 schema
|
|
||||||
if (isEdit && editData?.uiVariables) {
|
|
||||||
console.log('开始合并 UI 配置数据');
|
|
||||||
|
|
||||||
// 处理颜色值
|
|
||||||
const processColorValue = (value: any): string => {
|
|
||||||
if (!value || typeof value !== 'object') return value;
|
|
||||||
|
|
||||||
// 如果是颜色对象结构
|
|
||||||
if (value.metaColor) {
|
|
||||||
const { r, g, b } = value.metaColor;
|
|
||||||
// 转换为十六进制
|
|
||||||
const toHex = (n: number): string => {
|
|
||||||
const hex = n.toString(16);
|
|
||||||
return hex.length === 1 ? '0' + hex : hex;
|
|
||||||
};
|
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeUiVariables = (schemaObj: any, uiData: any, parentPath = ''): any => {
|
|
||||||
if (!schemaObj || typeof schemaObj !== 'object') return schemaObj;
|
|
||||||
console.log('正在处理 schema:', schemaObj);
|
|
||||||
console.log('UI 数据:', uiData);
|
|
||||||
console.log('当前路径:', parentPath);
|
|
||||||
|
|
||||||
const result = {...schemaObj};
|
|
||||||
|
|
||||||
// 颜色字段路径列表
|
|
||||||
const colorFields = [
|
|
||||||
'ports.groups.in.attrs.circle.fill',
|
|
||||||
'ports.groups.in.attrs.circle.stroke',
|
|
||||||
'ports.groups.out.attrs.circle.fill',
|
|
||||||
'ports.groups.out.attrs.circle.stroke',
|
|
||||||
'style.fill',
|
|
||||||
'style.stroke',
|
|
||||||
'style.iconColor'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (result.properties) {
|
|
||||||
Object.keys(result.properties).forEach(key => {
|
|
||||||
const currentPath = parentPath ? `${parentPath}.${key}` : key;
|
|
||||||
console.log('处理属性:', currentPath);
|
|
||||||
|
|
||||||
// 处理嵌套对象
|
|
||||||
if (result.properties[key].type === 'object') {
|
|
||||||
const nestedValue = currentPath.split('.').reduce((obj, key) => obj?.[key], uiData);
|
|
||||||
console.log('嵌套对象的值:', nestedValue);
|
|
||||||
if (nestedValue !== undefined) {
|
|
||||||
result.properties[key].default = nestedValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 处理基本类型
|
|
||||||
else {
|
|
||||||
const value = currentPath.split('.').reduce((obj, key) => obj?.[key], uiData);
|
|
||||||
console.log('属性值:', value);
|
|
||||||
if (value !== undefined) {
|
|
||||||
// 如果是颜色字段,处理颜色值
|
|
||||||
if (colorFields.some(field => currentPath.endsWith(field))) {
|
|
||||||
result.properties[key].default = processColorValue(value);
|
|
||||||
} else {
|
|
||||||
result.properties[key].default = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归处理嵌套属性
|
|
||||||
result.properties[key] = mergeUiVariables(
|
|
||||||
result.properties[key],
|
|
||||||
uiData,
|
|
||||||
currentPath
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.items) {
|
|
||||||
result.items = mergeUiVariables(result.items, uiData, parentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergedSchema = mergeUiVariables(schema, editData.uiVariables);
|
|
||||||
console.log('合并后的 schema:', mergedSchema);
|
|
||||||
return mergedSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('使用原始 schema');
|
|
||||||
return schema;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
header={{
|
|
||||||
title: '节点设计',
|
|
||||||
extra: [
|
|
||||||
<Button
|
|
||||||
key="save"
|
|
||||||
type="primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{display: 'flex', gap: '24px', padding: '24px'}}>
|
|
||||||
<Tabs
|
|
||||||
tabPosition="left"
|
|
||||||
type="card"
|
|
||||||
activeKey={isEdit && editData?.nodeType ?
|
|
||||||
nodeDefinitionsDefined.find(n => n.nodeType === editData.nodeType)?.nodeCode || selectedNode?.nodeCode :
|
|
||||||
selectedNode?.nodeCode || ''
|
|
||||||
}
|
|
||||||
onChange={(key) => {
|
|
||||||
const node = nodeDefinitionsDefined.find(n => n.nodeCode === key);
|
|
||||||
if (node) {
|
|
||||||
handleNodeSelect(node);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
items={nodeDefinitionsDefined.map(node => ({
|
|
||||||
key: node.nodeCode,
|
|
||||||
label: (
|
|
||||||
<div style={{padding: '4px 0'}}>
|
|
||||||
<div style={{fontSize: '14px', fontWeight: 500}}>
|
|
||||||
{node.nodeName}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: '#666',
|
|
||||||
marginTop: '4px'
|
|
||||||
}}>
|
|
||||||
{node.nodeCode}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
disabled: isEdit && editData?.nodeType && node.nodeType !== editData.nodeType
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
key={`${selectedNode?.nodeCode}-${activeTab}`}
|
|
||||||
>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="base.nodeType"
|
|
||||||
label="节点类型"
|
|
||||||
rules={[{required: true}]}
|
|
||||||
>
|
|
||||||
<Input disabled/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="base.nodeCode"
|
|
||||||
label="节点编码"
|
|
||||||
rules={[{required: true}]}
|
|
||||||
>
|
|
||||||
<Input disabled/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="base.nodeName"
|
|
||||||
label="节点名称"
|
|
||||||
rules={[{required: true}]}
|
|
||||||
>
|
|
||||||
<Input disabled/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
name="base.category"
|
|
||||||
label="节点类别"
|
|
||||||
>
|
|
||||||
<Input disabled/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Form.Item
|
|
||||||
name="base.description"
|
|
||||||
label="节点描述"
|
|
||||||
>
|
|
||||||
<Input.TextArea rows={4}/>
|
|
||||||
</Form.Item>
|
|
||||||
<Tabs
|
|
||||||
activeKey={activeTab}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
items={getAvailableTabs(selectedNode).map(tab => ({
|
|
||||||
key: tab.key,
|
|
||||||
label: (
|
|
||||||
<span style={{fontSize: '14px'}}>
|
|
||||||
{tab.label}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
children: tab.key === activeTab ? (
|
|
||||||
<div style={{
|
|
||||||
padding: '16px 0',
|
|
||||||
minHeight: '400px'
|
|
||||||
}}>
|
|
||||||
<FormRenderer
|
|
||||||
schema={getCurrentSchema()}
|
|
||||||
readOnly={tab.readonly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NodeDesignForm;
|
|
||||||
@ -1,372 +0,0 @@
|
|||||||
import React, {useState, useEffect} from 'react';
|
|
||||||
import {useNavigate} from 'react-router-dom';
|
|
||||||
import {PageContainer} from '@/components/ui/page-container';
|
|
||||||
import {PlusOutlined, DeleteOutlined} from '@ant-design/icons';
|
|
||||||
import {NodeTypeEnum, type NodeDesignDataResponse} from './types';
|
|
||||||
import * as service from './service';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import {Badge} from "@/components/ui/badge";
|
|
||||||
import {useToast} from "@/components/ui/use-toast";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import {DataTablePagination} from "@/components/ui/pagination";
|
|
||||||
|
|
||||||
// 节点类型标签样式映射
|
|
||||||
const nodeTypeStyles = {
|
|
||||||
[NodeTypeEnum.START_EVENT]: {
|
|
||||||
variant: 'success' as const,
|
|
||||||
label: '开始事件'
|
|
||||||
},
|
|
||||||
[NodeTypeEnum.END_EVENT]: {
|
|
||||||
variant: 'destructive' as const,
|
|
||||||
label: '结束事件'
|
|
||||||
},
|
|
||||||
[NodeTypeEnum.SCRIPT_TASK]: {
|
|
||||||
variant: 'default' as const,
|
|
||||||
label: '脚本任务'
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Column {
|
|
||||||
accessorKey?: keyof NodeDesignDataResponse;
|
|
||||||
id?: string;
|
|
||||||
header: string;
|
|
||||||
size: number;
|
|
||||||
cell?: (props: { row: { original: NodeDesignDataResponse } }) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NodeDesignList: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [detailVisible, setDetailVisible] = useState(false);
|
|
||||||
const [currentNode, setCurrentNode] = useState<NodeDesignDataResponse>();
|
|
||||||
const [list, setList] = useState<NodeDesignDataResponse[]>([]);
|
|
||||||
const [setLoading] = useState(false);
|
|
||||||
const [pagination, setPagination] = useState({
|
|
||||||
pageNum: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
totalElements: 0,
|
|
||||||
});
|
|
||||||
const {toast} = useToast();
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await service.getNodeDefinitions({
|
|
||||||
pageNum: pagination.pageNum,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
setList(response.content || []);
|
|
||||||
setPagination({
|
|
||||||
...pagination,
|
|
||||||
totalElements: response.totalElements,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "获取节点列表失败",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setPagination({
|
|
||||||
...pagination,
|
|
||||||
pageNum: page,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await service.deleteNodeDefinition(id);
|
|
||||||
toast({
|
|
||||||
title: "删除成功",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: "删除失败",
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
}, [pagination.pageNum, pagination.pageSize]);
|
|
||||||
|
|
||||||
const columns: Column[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'nodeCode',
|
|
||||||
header: '节点编码',
|
|
||||||
size: 180,
|
|
||||||
cell: ({row}) => (
|
|
||||||
<div className="font-medium">{row.original.nodeCode}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'nodeName',
|
|
||||||
header: '节点名称',
|
|
||||||
size: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'nodeType',
|
|
||||||
header: '节点类型',
|
|
||||||
size: 120,
|
|
||||||
cell: ({row}) => {
|
|
||||||
const style = nodeTypeStyles[row.original.nodeType as NodeTypeEnum] || {
|
|
||||||
variant: 'secondary' as const,
|
|
||||||
label: row.original.nodeType || '未知类型'
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Badge variant={style.variant}>
|
|
||||||
{style.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'panelVariablesSchema',
|
|
||||||
header: '面板变量',
|
|
||||||
size: 100,
|
|
||||||
cell: ({row}) => (
|
|
||||||
<Badge variant={row.original.panelVariablesSchema ? "outline" : "secondary"}>
|
|
||||||
{row.original.panelVariablesSchema ? '已配置' : '未配置'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'localVariablesSchema',
|
|
||||||
header: '本地变量',
|
|
||||||
size: 100,
|
|
||||||
cell: ({row}) => (
|
|
||||||
<Badge variant={row.original.localVariablesSchema ? "outline" : "secondary"}>
|
|
||||||
{row.original.localVariablesSchema ? '已配置' : '未配置'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'formVariablesSchema',
|
|
||||||
header: '表单变量',
|
|
||||||
size: 100,
|
|
||||||
cell: ({row}) => (
|
|
||||||
<Badge variant={row.original.formVariablesSchema ? "outline" : "secondary"}>
|
|
||||||
{row.original.formVariablesSchema ? '已配置' : '未配置'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: '操作',
|
|
||||||
size: 180,
|
|
||||||
cell: ({row}) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentNode(row.original);
|
|
||||||
setDetailVisible(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
查看详情
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate(`/workflow/node-design/design/${row.original.id}`)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive"
|
|
||||||
>
|
|
||||||
<DeleteOutlined className="mr-1"/>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>确定要删除该节点吗?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
此操作将永久删除该节点,且不可恢复。
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => handleDelete(row.original.id)}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-3xl font-bold tracking-tight">节点管理</h2>
|
|
||||||
<Button onClick={() => navigate('/workflow/node-design/create')}>
|
|
||||||
<PlusOutlined className="mr-1"/>
|
|
||||||
新建节点
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle>节点列表</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table minWidth="930px">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<TableHead
|
|
||||||
key={column.accessorKey || column.id}
|
|
||||||
width={`${column.size}px`}
|
|
||||||
sticky={column.id === 'actions'}
|
|
||||||
>
|
|
||||||
{column.header}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{list.map((item) => (
|
|
||||||
<TableRow key={item.id}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<TableCell
|
|
||||||
key={column.accessorKey || column.id}
|
|
||||||
width={`${column.size}px`}
|
|
||||||
sticky={column.id === 'actions'}
|
|
||||||
>
|
|
||||||
{column.cell
|
|
||||||
? column.cell({row: {original: item}})
|
|
||||||
: item[column.accessorKey!]}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<div className="flex justify-end border-t border-border bg-muted/40">
|
|
||||||
<DataTablePagination
|
|
||||||
pageIndex={pagination.pageNum}
|
|
||||||
pageSize={pagination.pageSize}
|
|
||||||
pageCount={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 节点详情弹窗 */}
|
|
||||||
<AlertDialog open={detailVisible} onOpenChange={setDetailVisible}>
|
|
||||||
<AlertDialogContent className="max-w-[800px]">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>节点详情</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">节点编码</div>
|
|
||||||
<div className="mt-1">{currentNode?.nodeCode}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">节点名称</div>
|
|
||||||
<div className="mt-1">{currentNode?.nodeName}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">节点类型</div>
|
|
||||||
<div className="mt-1">
|
|
||||||
{currentNode?.nodeType && (
|
|
||||||
<Badge variant={(nodeTypeStyles[currentNode.nodeType as NodeTypeEnum] || {}).variant || 'secondary'}>
|
|
||||||
{(nodeTypeStyles[currentNode.nodeType as NodeTypeEnum] || {}).label || currentNode.nodeType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">节点分类</div>
|
|
||||||
<div className="mt-1">{currentNode?.category}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">描述</div>
|
|
||||||
<div className="mt-1">{currentNode?.description || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">变量配置</div>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>面板变量:</span>
|
|
||||||
<Badge variant={currentNode?.panelVariablesSchema ? "outline" : "secondary"}>
|
|
||||||
{currentNode?.panelVariablesSchema ? '已配置' : '未配置'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>本地变量:</span>
|
|
||||||
<Badge variant={currentNode?.localVariablesSchema ? "outline" : "secondary"}>
|
|
||||||
{currentNode?.localVariablesSchema ? '已配置' : '未配置'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>表单变量:</span>
|
|
||||||
<Badge variant={currentNode?.formVariablesSchema ? "outline" : "secondary"}>
|
|
||||||
{currentNode?.formVariablesSchema ? '已配置' : '未配置'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onClick={() => setDetailVisible(false)}>关闭</AlertDialogCancel>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NodeDesignList;
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
// 节点设计相关服务
|
|
||||||
|
|
||||||
import request from '@/utils/request';
|
|
||||||
import type {NodeDesignQuery, NodeDefinitionResponse, NodeDesignDataResponse} from './types';
|
|
||||||
|
|
||||||
const BASE_URL = '/api/v1/workflow/node-definition';
|
|
||||||
|
|
||||||
// 获取节点设计列表
|
|
||||||
export const getNodeDefinitions = (params: NodeDesignQuery) =>
|
|
||||||
request.get<NodeDefinitionResponse>(`${BASE_URL}/page`, {params});
|
|
||||||
|
|
||||||
// 获取节点设计详情
|
|
||||||
export const getNodeDefinition = (id: number) =>
|
|
||||||
request.get<NodeDefinitionResponse>(`${BASE_URL}/${id}`);
|
|
||||||
|
|
||||||
|
|
||||||
// 获取已定义的节点类型配置
|
|
||||||
export const getNodeDefinitionsDefined = () =>
|
|
||||||
request.get<NodeDesignDataResponse[]>(`${BASE_URL}/defined`);
|
|
||||||
|
|
||||||
// 保存节点定义
|
|
||||||
export const saveNodeDefinition = (data: NodeDesignDataResponse) =>
|
|
||||||
request.post<void>(`${BASE_URL}`, data);
|
|
||||||
|
|
||||||
export const updateNodeDefinition = (id: number, data: NodeDesignDataResponse) =>
|
|
||||||
request.put<void>(`${BASE_URL}/${id}`, data);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除节点定义
|
|
||||||
* @param id 节点ID
|
|
||||||
*/
|
|
||||||
export const deleteNodeDefinition = (id: number) =>
|
|
||||||
request.delete<void>(`${BASE_URL}/${id}`);
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
// 节点设计相关类型定义
|
|
||||||
|
|
||||||
// 基础Schema接口
|
|
||||||
import {BaseQuery, BaseResponse} from "@/types/base";
|
|
||||||
|
|
||||||
export interface BaseSchema {
|
|
||||||
type: string;
|
|
||||||
properties: Record<string, any>;
|
|
||||||
required?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 节点变量Schema
|
|
||||||
export interface NodeVariablesSchema extends BaseSchema {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI配置中的大小接口
|
|
||||||
export interface NodeSize {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 端口样式配置
|
|
||||||
export interface PortStyle {
|
|
||||||
r: number;
|
|
||||||
fill: string;
|
|
||||||
stroke: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 端口属性配置
|
|
||||||
export interface PortAttributes {
|
|
||||||
circle: PortStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 端口位置类型
|
|
||||||
export type PortPosition = 'left' | 'right' | 'top' | 'bottom';
|
|
||||||
|
|
||||||
// 端口配置
|
|
||||||
export interface PortConfig {
|
|
||||||
attrs: PortAttributes;
|
|
||||||
position: PortPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 端口组配置
|
|
||||||
export interface PortGroups {
|
|
||||||
in: PortConfig;
|
|
||||||
out: PortConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 节点形状类型
|
|
||||||
export type NodeShape = 'rect' | 'circle' | 'diamond';
|
|
||||||
|
|
||||||
// 节点样式配置
|
|
||||||
export interface NodeStyle {
|
|
||||||
fill: string;
|
|
||||||
icon: string;
|
|
||||||
stroke: string;
|
|
||||||
iconColor: string;
|
|
||||||
strokeWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI变量配置
|
|
||||||
export interface UIVariables extends BaseSchema {
|
|
||||||
size: NodeSize;
|
|
||||||
ports: {
|
|
||||||
groups: PortGroups;
|
|
||||||
};
|
|
||||||
shape: NodeShape;
|
|
||||||
style: NodeStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 节点设计数据
|
|
||||||
export interface NodeDesignDataResponse extends BaseResponse {
|
|
||||||
nodeCode: string;
|
|
||||||
nodeName: string;
|
|
||||||
nodeType: string;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
panelVariablesSchema: NodeVariablesSchema | null;
|
|
||||||
localVariablesSchema: NodeVariablesSchema | null;
|
|
||||||
formVariablesSchema: NodeVariablesSchema | null;
|
|
||||||
uiVariables: UIVariables;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 节点类型枚举
|
|
||||||
export enum NodeTypeEnum {
|
|
||||||
START_EVENT = 'START_EVENT',
|
|
||||||
END_EVENT = 'END_EVENT',
|
|
||||||
SCRIPT_TASK = 'SCRIPT_TASK',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询参数接口
|
|
||||||
export interface NodeDesignQuery extends BaseQuery {
|
|
||||||
nodeCode?: string;
|
|
||||||
nodeName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页响应接口
|
|
||||||
export interface NodeDefinitionResponse extends BaseResponse {
|
|
||||||
nodeCode: string;
|
|
||||||
nodeName: string;
|
|
||||||
nodeType: NodeTypeEnum;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
panelVariablesSchema: NodeVariablesSchema | null;
|
|
||||||
uiVariables: UIVariables | null;
|
|
||||||
localVariablesSchema: NodeVariablesSchema | null;
|
|
||||||
panelVariables?: Record<string, any>;
|
|
||||||
localVariables?: Record<string, any>;
|
|
||||||
}
|
|
||||||
@ -35,8 +35,6 @@ const WorkflowDesign = lazy(() => import('../pages/Workflow/Design'));
|
|||||||
const WorkflowInstance = lazy(() => import('../pages/Workflow/Instance'));
|
const WorkflowInstance = lazy(() => import('../pages/Workflow/Instance'));
|
||||||
const WorkflowMonitor = lazy(() => import('../pages/Workflow/Monitor'));
|
const WorkflowMonitor = lazy(() => import('../pages/Workflow/Monitor'));
|
||||||
const LogStreamPage = lazy(() => import('../pages/LogStream'));
|
const LogStreamPage = lazy(() => import('../pages/LogStream'));
|
||||||
const NodeDesign = lazy(() => import('../pages/Workflow/NodeDesign'));
|
|
||||||
const NodeDesignForm = lazy(() => import('../pages/Workflow/NodeDesign/Design'));
|
|
||||||
const ApplicationList = lazy(() => import('../pages/Deploy/Application/List'));
|
const ApplicationList = lazy(() => import('../pages/Deploy/Application/List'));
|
||||||
const EnvironmentList = lazy(() => import('../pages/Deploy/Environment/List'));
|
const EnvironmentList = lazy(() => import('../pages/Deploy/Environment/List'));
|
||||||
const DeploymentConfigList = lazy(() => import('../pages/Deploy/Deployment/List'));
|
const DeploymentConfigList = lazy(() => import('../pages/Deploy/Deployment/List'));
|
||||||
@ -44,6 +42,7 @@ const JenkinsManagerList = lazy(() => import('../pages/Deploy/JenkinsManager/Lis
|
|||||||
const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
|
const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
|
||||||
const External = lazy(() => import('../pages/Deploy/External'));
|
const External = lazy(() => import('../pages/Deploy/External'));
|
||||||
const TeamList = lazy(() => import('../pages/Deploy/Team/List'));
|
const TeamList = lazy(() => import('../pages/Deploy/Team/List'));
|
||||||
|
const ScheduleJobList = lazy(() => import('../pages/Deploy/ScheduleJob/List'));
|
||||||
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
|
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
|
||||||
const FormDefinitionList = lazy(() => import('../pages/Form/Definition'));
|
const FormDefinitionList = lazy(() => import('../pages/Form/Definition'));
|
||||||
const FormDefinitionDesigner = lazy(() => import('../pages/Form/Definition/Designer'));
|
const FormDefinitionDesigner = lazy(() => import('../pages/Form/Definition/Designer'));
|
||||||
@ -92,6 +91,10 @@ const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: 'deployment',
|
path: 'deployment',
|
||||||
element: <Suspense fallback={<LoadingComponent/>}><DeploymentConfigList/></Suspense>
|
element: <Suspense fallback={<LoadingComponent/>}><DeploymentConfigList/></Suspense>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'schedule-jobs',
|
||||||
|
element: <Suspense fallback={<LoadingComponent/>}><ScheduleJobList/></Suspense>
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -234,35 +237,6 @@ const router = createBrowserRouter([
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'node-design',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: true,
|
|
||||||
element: (
|
|
||||||
<Suspense fallback={<LoadingComponent/>}>
|
|
||||||
<NodeDesign/>
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'create',
|
|
||||||
element: (
|
|
||||||
<Suspense fallback={<LoadingComponent/>}>
|
|
||||||
<NodeDesignForm/>
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'design/:id',
|
|
||||||
element: (
|
|
||||||
<Suspense fallback={<LoadingComponent/>}>
|
|
||||||
<NodeDesignForm/>
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'monitor',
|
path: 'monitor',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user