You've already forked DataMate
Integrated Redux for state management with auth and settings slices. (#117)
* feat: Implement DatasetFileTransfer component for file selection and management * feat: Add pagination support to file list in Overview component * feat: add DatasetFileTransfer and TagManagement components - Added DatasetFileTransfer component for managing dataset files. - Introduced TagManagement component for handling tags. - Integrated Redux for state management with auth and settings slices. - Updated package.json to include @reduxjs/toolkit and react-redux dependencies. - Refactored existing components to utilize new DatasetFileTransfer and TagManagement components. - Implemented hooks for typed dispatch and selector in Redux. - Enhanced CreateKnowledgeBase and SynthesisTask components to support new features.
This commit is contained in:
269
frontend/src/components/business/DatasetFileTransfer.tsx
Normal file
269
frontend/src/components/business/DatasetFileTransfer.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Button, Input, Table } from "antd";
|
||||
import { RightOutlined } from "@ant-design/icons";
|
||||
import { mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import {
|
||||
Dataset,
|
||||
DatasetFile,
|
||||
DatasetType,
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
import {
|
||||
queryDatasetFilesUsingGet,
|
||||
queryDatasetsUsingGet,
|
||||
} from "@/pages/DataManagement/dataset.api";
|
||||
import { formatBytes } from "@/utils/unit";
|
||||
import { useDebouncedEffect } from "@/hooks/useDebouncedEffect";
|
||||
|
||||
interface DatasetFileTransferProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
open: boolean;
|
||||
selectedFilesMap: { [key: string]: DatasetFile };
|
||||
onSelectedFilesChange: (filesMap: { [key: string]: DatasetFile }) => void;
|
||||
}
|
||||
|
||||
const fileCols = [
|
||||
{
|
||||
title: "所属数据集",
|
||||
dataIndex: "datasetName",
|
||||
key: "datasetName",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "文件名",
|
||||
dataIndex: "fileName",
|
||||
key: "fileName",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "fileSize",
|
||||
key: "fileSize",
|
||||
ellipsis: true,
|
||||
render: formatBytes,
|
||||
},
|
||||
];
|
||||
|
||||
// Customize Table Transfer
|
||||
const DatasetFileTransfer: React.FC<DatasetFileTransferProps> = ({
|
||||
open,
|
||||
selectedFilesMap,
|
||||
onSelectedFilesChange,
|
||||
...props
|
||||
}) => {
|
||||
const [datasets, setDatasets] = React.useState<Dataset[]>([]);
|
||||
const [datasetSearch, setDatasetSearch] = React.useState<string>("");
|
||||
const [datasetPagination, setDatasetPagination] = React.useState<{
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}>({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
const [files, setFiles] = React.useState<DatasetFile[]>([]);
|
||||
const [filesSearch, setFilesSearch] = React.useState<string>("");
|
||||
const [filesPagination, setFilesPagination] = React.useState<{
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}>({ current: 1, pageSize: 10, total: 0 });
|
||||
|
||||
const [showFiles, setShowFiles] = React.useState<boolean>(false);
|
||||
const [selectedDataset, setSelectedDataset] = React.useState<Dataset | null>(
|
||||
null
|
||||
);
|
||||
const [datasetSelections, setDatasetSelections] = React.useState<Dataset[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
const { data } = await queryDatasetsUsingGet({
|
||||
page: datasetPagination.current - 1,
|
||||
size: datasetPagination.pageSize,
|
||||
keyword: datasetSearch,
|
||||
type: DatasetType.TEXT,
|
||||
});
|
||||
setDatasets(data.content.map(mapDataset) || []);
|
||||
setDatasetPagination((prev) => ({
|
||||
...prev,
|
||||
total: data.totalElements,
|
||||
}));
|
||||
};
|
||||
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
fetchDatasets();
|
||||
},
|
||||
[datasetSearch, datasetPagination.pageSize, datasetPagination.current],
|
||||
300
|
||||
);
|
||||
|
||||
const fetchFiles = async () => {
|
||||
if (!selectedDataset) return;
|
||||
const { data } = await queryDatasetFilesUsingGet(selectedDataset.id, {
|
||||
page: filesPagination.current - 1,
|
||||
size: filesPagination.pageSize,
|
||||
keyword: filesSearch,
|
||||
});
|
||||
setFiles(
|
||||
data.content.map((item) => ({
|
||||
...item,
|
||||
key: item.id,
|
||||
datasetName: selectedDataset.name,
|
||||
})) || []
|
||||
);
|
||||
setFilesPagination((prev) => ({
|
||||
...prev,
|
||||
total: data.totalElements,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDataset) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [selectedDataset]);
|
||||
|
||||
const toggleSelectFile = (record: DatasetFile) => {
|
||||
if (!selectedFilesMap[record.id]) {
|
||||
onSelectedFilesChange({
|
||||
...selectedFilesMap,
|
||||
[record.id]: record,
|
||||
});
|
||||
} else {
|
||||
const newSelectedFiles = { ...selectedFilesMap };
|
||||
delete newSelectedFiles[record.id];
|
||||
onSelectedFilesChange(newSelectedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// 重置状态
|
||||
setDatasets([]);
|
||||
setDatasetSearch("");
|
||||
setDatasetPagination({ current: 1, pageSize: 10, total: 0 });
|
||||
setFiles([]);
|
||||
setFilesSearch("");
|
||||
setFilesPagination({ current: 1, pageSize: 10, total: 0 });
|
||||
setShowFiles(false);
|
||||
setSelectedDataset(null);
|
||||
setDatasetSelections([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const datasetCols = [
|
||||
{
|
||||
title: "数据集名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "文件数",
|
||||
dataIndex: "fileCount",
|
||||
key: "fileCount",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "totalSize",
|
||||
key: "totalSize",
|
||||
ellipsis: true,
|
||||
render: formatBytes,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div className="grid grid-cols-25 gap-4 w-full">
|
||||
<div className="border-card flex flex-col col-span-12">
|
||||
<div className="border-bottom p-2 font-bold">选择数据集</div>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="搜索数据集名称..."
|
||||
value={datasetSearch}
|
||||
allowClear
|
||||
onChange={(e) => setDatasetSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
scroll={{ y: 400 }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
rowClassName={(record) =>
|
||||
selectedDataset?.id === record.id ? "bg-blue-100" : ""
|
||||
}
|
||||
onRow={(record: Dataset) => ({
|
||||
onClick: () => {
|
||||
setSelectedDataset(record);
|
||||
if (!datasetSelections.find((d) => d.id === record.id)) {
|
||||
setDatasetSelections([...datasetSelections, record]);
|
||||
} else {
|
||||
setDatasetSelections(
|
||||
datasetSelections.filter((d) => d.id !== record.id)
|
||||
);
|
||||
}
|
||||
},
|
||||
})}
|
||||
dataSource={datasets}
|
||||
columns={datasetCols}
|
||||
pagination={datasetPagination}
|
||||
/>
|
||||
</div>
|
||||
<RightOutlined />
|
||||
<div className="border-card flex flex-col col-span-12">
|
||||
<div className="border-bottom p-2 font-bold">选择文件</div>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="搜索文件名称..."
|
||||
value={filesSearch}
|
||||
onChange={(e) => setFilesSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
scroll={{ y: 400 }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={files}
|
||||
columns={fileCols.slice(1, fileCols.length)}
|
||||
pagination={filesPagination}
|
||||
onRow={(record: DatasetFile) => ({
|
||||
onClick: () => toggleSelectFile(record),
|
||||
})}
|
||||
rowSelection={{
|
||||
type: "checkbox",
|
||||
onSelectAll: (selected, _, changeRows) => {
|
||||
const newSelectedFiles = { ...selectedFilesMap };
|
||||
if (selected) {
|
||||
changeRows.forEach((row) => {
|
||||
newSelectedFiles[row.id] = row;
|
||||
});
|
||||
} else {
|
||||
changeRows.forEach((row) => {
|
||||
delete newSelectedFiles[row.id];
|
||||
});
|
||||
}
|
||||
onSelectedFilesChange(newSelectedFiles);
|
||||
},
|
||||
selectedRowKeys: Object.keys(selectedFilesMap),
|
||||
onSelect: toggleSelectFile,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="mt-4" onClick={() => setShowFiles(!showFiles)}>
|
||||
{showFiles ? "取消预览" : "预览"}
|
||||
</Button>
|
||||
<div hidden={!showFiles}>
|
||||
<Table
|
||||
scroll={{ y: 400 }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={Object.values(selectedFilesMap)}
|
||||
columns={fileCols}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatasetFileTransfer;
|
||||
251
frontend/src/components/business/TagManagement.tsx
Normal file
251
frontend/src/components/business/TagManagement.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Drawer, Input, Button, App } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Edit, Save, TagIcon, X, Trash } from "lucide-react";
|
||||
import { TagItem } from "@/pages/DataManagement/dataset.model";
|
||||
|
||||
interface CustomTagProps {
|
||||
isEditable?: boolean;
|
||||
tag: { id: number; name: string };
|
||||
editingTag?: string | null;
|
||||
editingTagValue?: string;
|
||||
setEditingTag?: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setEditingTagValue?: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleEditTag?: (tag: { id: number; name: string }, value: string) => void;
|
||||
handleCancelEdit?: (tag: { id: number; name: string }) => void;
|
||||
handleDeleteTag?: (tag: { id: number; name: string }) => void;
|
||||
}
|
||||
|
||||
function CustomTag({
|
||||
isEditable = false,
|
||||
tag,
|
||||
editingTag,
|
||||
editingTagValue,
|
||||
setEditingTag,
|
||||
setEditingTagValue,
|
||||
handleEditTag,
|
||||
handleCancelEdit,
|
||||
handleDeleteTag,
|
||||
}: CustomTagProps) {
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-4 py-2 border-card hover:bg-gray-50"
|
||||
>
|
||||
{editingTag?.id === tag.id ? (
|
||||
<div className="flex gap-2 flex-1">
|
||||
<Input
|
||||
value={editingTagValue}
|
||||
onChange={(e) => setEditingTagValue?.(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEditTag?.(tag, editingTagValue);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingTag?.(null);
|
||||
setEditingTagValue?.("");
|
||||
}
|
||||
}}
|
||||
className="h-6 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleEditTag(tag, editingTagValue)}
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<Save className="w-3 h-3" />}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => handleCancelEdit?.(tag)}
|
||||
icon={<X className="w-3 h-3" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm">{tag.name}</span>
|
||||
{isEditable && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setEditingTag?.(tag);
|
||||
setEditingTagValue?.(tag.name);
|
||||
}}
|
||||
icon={<Edit className="w-3 h-3" />}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => handleDeleteTag?.(tag)}
|
||||
icon={<Trash className="w-3 h-3" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TagManager: React.FC = ({
|
||||
onFetch,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
}: {
|
||||
onFetch: () => Promise<any>;
|
||||
onCreate: (tag: Pick<TagItem, "name">) => Promise<{ ok: boolean }>;
|
||||
onDelete: (tagId: number) => Promise<{ ok: boolean }>;
|
||||
onUpdate: (tag: TagItem) => Promise<{ ok: boolean }>;
|
||||
}) => {
|
||||
const [showTagManager, setShowTagManager] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const [tags, setTags] = useState<{ id: number; name: string }[]>([]);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const [editingTag, setEditingTag] = useState<string | null>(null);
|
||||
const [editingTagValue, setEditingTagValue] = useState("");
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
if (!onFetch) return;
|
||||
try {
|
||||
const { data } = await onFetch?.();
|
||||
setTags(data || []);
|
||||
} catch (e) {
|
||||
message.error("获取标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const addTag = async (tag: string) => {
|
||||
try {
|
||||
await onCreate?.({
|
||||
name: tag,
|
||||
});
|
||||
fetchTags();
|
||||
setNewTag("");
|
||||
message.success("标签添加成功");
|
||||
} catch (error) {
|
||||
message.error("添加标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 删除标签
|
||||
const deleteTag = async (tag: TagItem) => {
|
||||
try {
|
||||
await onDelete?.(tag.id);
|
||||
fetchTags();
|
||||
message.success("标签删除成功");
|
||||
} catch (error) {
|
||||
message.error("删除标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
const updateTag = async (oldTag: TagItem, newTag: string) => {
|
||||
try {
|
||||
await onUpdate?.({ ...oldTag, name: newTag });
|
||||
fetchTags();
|
||||
message.success("标签更新成功");
|
||||
} catch (error) {
|
||||
message.error("更新标签失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewTag = () => {
|
||||
if (newTag.trim()) {
|
||||
addTag(newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTag = (tag: TagItem, value: string) => {
|
||||
if (value.trim()) {
|
||||
updateTag(tag, value.trim());
|
||||
setEditingTag(null);
|
||||
setEditingTagValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = (tag: string) => {
|
||||
setEditingTag(null);
|
||||
setEditingTagValue("");
|
||||
};
|
||||
|
||||
const handleDeleteTag = (tag: TagItem) => {
|
||||
deleteTag(tag);
|
||||
setEditingTag(null);
|
||||
setEditingTagValue("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showTagManager) fetchTags();
|
||||
}, [showTagManager]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon={<TagIcon className="w-4 h-4 mr-2" />}
|
||||
onClick={() => setShowTagManager(true)}
|
||||
>
|
||||
标签管理
|
||||
</Button>
|
||||
<Drawer
|
||||
open={showTagManager}
|
||||
onClose={() => setShowTagManager(false)}
|
||||
title="标签管理"
|
||||
width={500}
|
||||
>
|
||||
<div className="space-y-4 flex-overflow">
|
||||
{/* Add New Tag */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入标签名称..."
|
||||
value={newTag}
|
||||
allowClear
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addTag(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleCreateNewTag}
|
||||
disabled={!newTag.trim()}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-overflow">
|
||||
<div className="overflow-auto grid grid-cols-2 gap-2">
|
||||
{tags.map((tag) => (
|
||||
<CustomTag
|
||||
isEditable
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
editingTag={editingTag}
|
||||
editingTagValue={editingTagValue}
|
||||
setEditingTag={setEditingTag}
|
||||
setEditingTagValue={setEditingTagValue}
|
||||
handleEditTag={handleEditTag}
|
||||
handleCancelEdit={handleCancelEdit}
|
||||
handleDeleteTag={handleDeleteTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagManager;
|
||||
162
frontend/src/components/business/TaskPopover.tsx
Normal file
162
frontend/src/components/business/TaskPopover.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Button, Popover, Progress } from "antd";
|
||||
import { Calendar, Clock, Play, Trash2, X } from "lucide-react";
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
scheduleConfig: {
|
||||
type: string;
|
||||
cronExpression?: string;
|
||||
executionCount?: number;
|
||||
maxExecutions?: number;
|
||||
};
|
||||
nextExecution?: string;
|
||||
importConfig: {
|
||||
source?: string;
|
||||
};
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function TaskPopover() {
|
||||
const tasks: TaskItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "导入客户数据",
|
||||
status: "importing",
|
||||
progress: 65,
|
||||
scheduleConfig: {
|
||||
type: "manual",
|
||||
},
|
||||
importConfig: {
|
||||
source: "local",
|
||||
},
|
||||
createdAt: "2025-07-29 14:23",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "定时同步订单",
|
||||
status: "waiting",
|
||||
progress: 0,
|
||||
scheduleConfig: {
|
||||
type: "scheduled",
|
||||
cronExpression: "0 0 * * *",
|
||||
executionCount: 3,
|
||||
maxExecutions: 10,
|
||||
},
|
||||
nextExecution: "2025-07-31 00:00",
|
||||
importConfig: {
|
||||
source: "api",
|
||||
},
|
||||
createdAt: "2025-07-28 09:10",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "清理历史日志",
|
||||
status: "finished",
|
||||
progress: 100,
|
||||
scheduleConfig: {
|
||||
type: "manual",
|
||||
},
|
||||
importConfig: {
|
||||
source: "system",
|
||||
},
|
||||
createdAt: "2025-07-27 17:45",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
content={
|
||||
<div className="w-[500px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900">近期任务</h3>
|
||||
<Button type="text" className="h-6 w-6 p-0">
|
||||
<X className="w-4 h-4 text-black-400 hover:text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">暂无创建任务</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-3 border-card hover:bg-gray-50"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm truncate flex-1">
|
||||
{task.name}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{task.status === "waiting" && (
|
||||
<Button
|
||||
className="h-6 w-6 p-0 text-blue-500 hover:text-blue-700"
|
||||
title="立即执行"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button className="h-6 w-6 p-0 text-gray-400 hover:text-red-500">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.status === "importing" && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>导入进度</span>
|
||||
<span>{Math.round(task.progress)}%</span>
|
||||
</div>
|
||||
<Progress percent={task.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Information */}
|
||||
{task.scheduleConfig.type === "scheduled" && (
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span className="font-medium">定时任务</span>
|
||||
</div>
|
||||
<div>Cron: {task.scheduleConfig.cronExpression}</div>
|
||||
{task.nextExecution && (
|
||||
<div>下次执行: {task.nextExecution}</div>
|
||||
)}
|
||||
<div>
|
||||
执行次数: {task.scheduleConfig.executionCount || 0}/
|
||||
{task.scheduleConfig.maxExecutions || 10}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
{task.importConfig.source === "local"
|
||||
? "本地上传"
|
||||
: task.importConfig.source || "未知来源"}
|
||||
</span>
|
||||
<span>{task.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button block>任务中心</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user