You've already forked DataMate
feat:修复下载数据集问题、删除数据确认框、修改标题、添加列表轮询刷新 (#16)
* refactor: clean up tag management and dataset handling, update API endpoints * feat: add showTime prop to DevelopmentInProgress component across multiple pages * refactor: update component styles and improve layout with new utility classes * feat: enhance useFetchData hook with polling functionality and improve task progress tracking * feat: enhance dataset management features with improved tag handling, download functionality, and UI updates * feat: Enhance DatasetDetail component with delete functionality and improved download handling feat: Add automatic data refresh and improved user feedback in DatasetManagementPage fix: Update dataset API to streamline download functionality and improve error handling * feat: Clear new tag input after successful addition in TagManager
This commit is contained in:
115
frontend/src/components/ActionDropdown.tsx
Normal file
115
frontend/src/components/ActionDropdown.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Dropdown, Popconfirm, Button, Space } from "antd";
|
||||
import { EllipsisOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ActionItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
danger?: boolean;
|
||||
confirm?: {
|
||||
title: string;
|
||||
description?: string;
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ActionDropdownProps {
|
||||
actions?: ActionItem[];
|
||||
onAction?: (key: string, action: ActionItem) => void;
|
||||
placement?:
|
||||
| "bottomRight"
|
||||
| "topLeft"
|
||||
| "topCenter"
|
||||
| "topRight"
|
||||
| "bottomLeft"
|
||||
| "bottomCenter"
|
||||
| "top"
|
||||
| "bottom";
|
||||
}
|
||||
|
||||
const ActionDropdown = ({
|
||||
actions = [],
|
||||
onAction,
|
||||
placement = "bottomRight",
|
||||
}: ActionDropdownProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleActionClick = (action: ActionItem) => {
|
||||
if (action.confirm) {
|
||||
// 如果有确认框,不立即执行,等待确认
|
||||
return;
|
||||
}
|
||||
// 执行操作
|
||||
onAction?.(action.key, action);
|
||||
// 如果没有确认框,则立即关闭 Dropdown
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className="bg-white p-2 rounded shadow-md">
|
||||
<Space direction="vertical" className="w-full">
|
||||
{actions.map((action) => {
|
||||
if (action.confirm) {
|
||||
return (
|
||||
<Popconfirm
|
||||
key={action.key}
|
||||
title={action.confirm.title}
|
||||
description={action.confirm.description}
|
||||
onConfirm={() => {
|
||||
onAction?.(action.key, action);
|
||||
setOpen(false);
|
||||
}}
|
||||
okText={action.confirm.okText || "确定"}
|
||||
cancelText={action.confirm.cancelText || "取消"}
|
||||
okType={action.danger ? "danger" : "primary"}
|
||||
styles={{ root: { zIndex: 9999 } }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="w-full text-left"
|
||||
danger={action.danger}
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
className="w-full"
|
||||
size="small"
|
||||
type="text"
|
||||
danger={action.danger}
|
||||
icon={action.icon}
|
||||
onClick={() => handleActionClick(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={dropdownContent}
|
||||
trigger={["click"]}
|
||||
placement={placement}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EllipsisOutlined style={{ fontSize: 24 }} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionDropdown;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Input, Popover, theme, Tag } from "antd";
|
||||
import { Button, Input, Popover, theme, Tag, Empty } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -64,30 +64,35 @@ export default function AddTagPopover({
|
||||
open={showPopover}
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
onOpenChange={setShowPopover}
|
||||
content={
|
||||
<div className="space-y-4 w-[300px]">
|
||||
<h4 className="font-medium border-b pb-2 border-gray-100">
|
||||
添加标签
|
||||
</h4>
|
||||
{/* Available Tags */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm">选择现有标签</h5>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{availableTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="h-7 w-full justify-start text-xs cursor-pointer flex items-center px-2 rounded hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
onAddTag?.(tag.name);
|
||||
setShowPopover(false);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined className="w-3 h-3 mr-1" />
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{availableTags?.length ? (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm">选择现有标签</h5>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{availableTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="h-7 w-full justify-start text-xs cursor-pointer flex items-center px-2 rounded hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
onAddTag?.(tag.name);
|
||||
setShowPopover(false);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined className="w-3 h-3 mr-1" />
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="没有可用标签,请先创建标签。" />
|
||||
)}
|
||||
|
||||
{/* Create New Tag */}
|
||||
<div className="space-y-2 border-t border-gray-100 pt-3">
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Tag, Pagination, Dropdown, Tooltip, Empty, Popover } from "antd";
|
||||
import {
|
||||
EllipsisOutlined,
|
||||
ClockCircleOutlined,
|
||||
StarFilled,
|
||||
} from "@ant-design/icons";
|
||||
Tag,
|
||||
Pagination,
|
||||
Tooltip,
|
||||
Empty,
|
||||
Popover,
|
||||
Menu,
|
||||
Popconfirm,
|
||||
} from "antd";
|
||||
import { ClockCircleOutlined, StarFilled } from "@ant-design/icons";
|
||||
import type { ItemType } from "antd/es/menu/interface";
|
||||
import { formatDateTime } from "@/utils/unit";
|
||||
import ActionDropdown from "./ActionDropdown";
|
||||
|
||||
interface BaseCardDataType {
|
||||
id: string | number;
|
||||
@@ -37,6 +42,7 @@ interface CardViewProps<T> {
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
icon?: React.JSX.Element;
|
||||
onClick?: (item: T) => void;
|
||||
}[]
|
||||
@@ -167,84 +173,129 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
|
||||
|
||||
const ops = (item) =>
|
||||
typeof operations === "function" ? operations(item) : operations;
|
||||
|
||||
const menu = (item) => {
|
||||
const ops =
|
||||
typeof operations === "function" ? operations(item) : operations;
|
||||
<Menu>
|
||||
{ops.map((op) => {
|
||||
if (op?.danger) {
|
||||
return (
|
||||
<Menu.Item key={op?.key} disabled icon={op?.icon}>
|
||||
<Popconfirm
|
||||
title="确定删除吗?"
|
||||
description="此操作不可撤销"
|
||||
onConfirm={op.onClick ? () => op.onClick(item) : undefined}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
// 阻止事件冒泡,避免 Dropdown 关闭
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
color: "inherit",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{op.icon}
|
||||
{op.label}
|
||||
</div>
|
||||
</Popconfirm>
|
||||
</Menu.Item>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Menu.Item key={op?.key} onClick={op?.onClick} icon={op?.icon}>
|
||||
{op?.label}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</Menu>;
|
||||
};
|
||||
return (
|
||||
<div className="flex-overflow-hidden">
|
||||
<div className="flex-overflow-auto grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
<div className="overflow-auto grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-card p-4 bg-white hover:shadow-lg transition-shadow duration-200"
|
||||
>
|
||||
<div className="flex flex-col space-y-4 h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{item?.icon && (
|
||||
<div
|
||||
className={`flex-shrink-0 w-12 h-12 ${
|
||||
item?.iconColor ||
|
||||
"bg-gradient-to-br from-blue-100 to-blue-200"
|
||||
} rounded-lg flex items-center justify-center`}
|
||||
>
|
||||
{item?.icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3
|
||||
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate ${
|
||||
onView ? "cursor-pointer hover:text-blue-600" : ""
|
||||
}`}
|
||||
onClick={() => onView?.(item)}
|
||||
<div
|
||||
className="flex flex-col space-y-4 h-full"
|
||||
onClick={() => onView?.(item)}
|
||||
style={{ cursor: onView ? "pointer" : "default" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{item?.icon && (
|
||||
<div
|
||||
className={`flex-shrink-0 w-12 h-12 ${
|
||||
item?.iconColor ||
|
||||
"bg-gradient-to-br from-blue-100 to-blue-200"
|
||||
} rounded-lg flex items-center justify-center`}
|
||||
>
|
||||
{item?.name}
|
||||
</h3>
|
||||
{item?.status && (
|
||||
<Tag color={item?.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs py-0.5">
|
||||
<span>{item?.status?.icon}</span>
|
||||
<span>{item?.status?.label}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
{item?.icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3
|
||||
className={`text-base flex-1 text-ellipsis overflow-hidden whitespace-nowrap font-semibold text-gray-900 truncate`}
|
||||
>
|
||||
{item?.name}
|
||||
</h3>
|
||||
{item?.status && (
|
||||
<Tag color={item?.status?.color}>
|
||||
<div className="flex items-center gap-2 text-xs py-0.5">
|
||||
<span>{item?.status?.icon}</span>
|
||||
<span>{item?.status?.label}</span>
|
||||
</div>
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onFavorite && (
|
||||
<StarFilled
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onFavorite?.(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onFavorite && (
|
||||
<StarFilled
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
color: isFavorite?.(item) ? "#ffcc00ff" : "#d1d5db",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onFavorite?.(item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
{/* Tags */}
|
||||
<TagsRenderer tags={item?.tags || []} />
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
{/* Tags */}
|
||||
<TagsRenderer tags={item?.tags || []} />
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
|
||||
<Tooltip title={item?.description}>
|
||||
{item?.description}
|
||||
</Tooltip>
|
||||
</p>
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 text-xs text-ellipsis overflow-hidden whitespace-nowrap text-xs line-clamp-2 mt-2">
|
||||
<Tooltip title={item?.description}>
|
||||
{item?.description}
|
||||
</Tooltip>
|
||||
</p>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-2 gap-4 py-3">
|
||||
{item?.statistics?.map((stat, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{stat?.label}:
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-2 gap-4 py-3">
|
||||
{item?.statistics?.map((stat, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="text-sm text-gray-500">
|
||||
{stat?.label}:
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900">
|
||||
{stat?.value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-base font-semibold text-gray-900">
|
||||
{stat?.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -257,24 +308,15 @@ function CardView<T extends BaseCardDataType>(props: CardViewProps<T>) {
|
||||
</div>
|
||||
</div>
|
||||
{operations && (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: ops(item),
|
||||
onClick: ({ key }) => {
|
||||
const operation = ops(item).find(
|
||||
(op) => op.key === key
|
||||
);
|
||||
if (operation?.onClick) {
|
||||
operation.onClick(item);
|
||||
}
|
||||
},
|
||||
<ActionDropdown
|
||||
actions={ops(item)}
|
||||
onAction={(key) => {
|
||||
const operation = ops(item).find((op) => op.key === key);
|
||||
if (operation?.onClick) {
|
||||
operation.onClick(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="cursor-pointer">
|
||||
<EllipsisOutlined style={{ fontSize: 24 }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { Database } from "lucide-react";
|
||||
import { Card, Dropdown, Button, Tag, Tooltip } from "antd";
|
||||
import { Card, Button, Tag, Tooltip, Popconfirm } from "antd";
|
||||
import type { ItemType } from "antd/es/menu/interface";
|
||||
import AddTagPopover from "./AddTagPopover";
|
||||
import ActionDropdown from "./ActionDropdown";
|
||||
|
||||
interface StatisticItem {
|
||||
icon: React.ReactNode;
|
||||
@@ -100,30 +101,38 @@ function DetailHeader<T>({
|
||||
{operations.map((op) => {
|
||||
if (op.isDropdown) {
|
||||
return (
|
||||
<Dropdown
|
||||
key={op.key}
|
||||
menu={{
|
||||
items: op.items as ItemType[],
|
||||
onClick: op.onMenuClick,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={op.label}>
|
||||
<Button icon={op.icon} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<ActionDropdown
|
||||
actions={op?.items}
|
||||
onAction={op?.onMenuClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (op.confirm) {
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Popconfirm
|
||||
key={op.key}
|
||||
title={op.confirm.title}
|
||||
description={op.confirm.description}
|
||||
onConfirm={() => {
|
||||
op?.onClick();
|
||||
}}
|
||||
okText={op.confirm.okText || "确定"}
|
||||
cancelText={op.confirm.cancelText || "取消"}
|
||||
okType={op.danger ? "danger" : "primary"}
|
||||
overlayStyle={{ zIndex: 9999 }}
|
||||
>
|
||||
<Button icon={op.icon} danger={op.danger} />
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={op.key} title={op.label}>
|
||||
<Button
|
||||
key={op.key}
|
||||
onClick={op.onClick}
|
||||
className={
|
||||
op.danger
|
||||
? "text-red-600 border-red-200 bg-transparent"
|
||||
: ""
|
||||
}
|
||||
icon={op.icon}
|
||||
danger={op.danger}
|
||||
onClick={op.onClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -128,6 +128,7 @@ const TagManager: React.FC = ({
|
||||
name: tag,
|
||||
});
|
||||
fetchTags();
|
||||
setNewTag("");
|
||||
message.success("标签添加成功");
|
||||
} catch (error) {
|
||||
message.error("添加标签失败");
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
// 首页数据获取
|
||||
import { useState } from "react";
|
||||
// 支持轮询功能,使用示例:
|
||||
// const { fetchData, startPolling, stopPolling, isPolling } = useFetchData(
|
||||
// fetchFunction,
|
||||
// mapFunction,
|
||||
// 5000, // 5秒轮询一次,默认30秒
|
||||
// true, // 是否自动开始轮询,默认 true
|
||||
// [fetchStatistics, fetchOtherData] // 额外的轮询函数数组
|
||||
// );
|
||||
//
|
||||
// startPolling(); // 开始轮询
|
||||
// stopPolling(); // 停止轮询
|
||||
// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时
|
||||
// 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useDebouncedEffect } from "./useDebouncedEffect";
|
||||
import Loading from "@/utils/loading";
|
||||
import { App } from "antd";
|
||||
import { AnyObject } from "antd/es/_util/type";
|
||||
|
||||
export default function useFetchData<T>(
|
||||
fetchFunc: (params?: any) => Promise<any>,
|
||||
mapDataFunc: (data: any) => T = (data) => data as T
|
||||
mapDataFunc: (data: AnyObject) => T = (data) => data as T,
|
||||
pollingInterval: number = 30000, // 默认30秒轮询一次
|
||||
autoRefresh: boolean = true,
|
||||
additionalPollingFuncs: (() => Promise<any>)[] = [] // 额外的轮询函数
|
||||
) {
|
||||
const { message } = App.useApp();
|
||||
|
||||
// 轮询相关状态
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 表格数据
|
||||
const [tableData, setTableData] = useState<T[]>([]);
|
||||
// 设置加载状态
|
||||
@@ -55,39 +77,117 @@ export default function useFetchData<T>(
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
async function fetchData(extraParams = {}) {
|
||||
const { keyword, filter, current, pageSize } = searchParams;
|
||||
Loading.show();
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await fetchFunc({
|
||||
...filter,
|
||||
...extraParams,
|
||||
keyword,
|
||||
type: getFirstOfArray(filter?.type) || undefined,
|
||||
status: getFirstOfArray(filter?.status) || undefined,
|
||||
tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
|
||||
page: current - 1,
|
||||
size: pageSize,
|
||||
});
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: data?.totalElements || 0,
|
||||
}));
|
||||
let result = [];
|
||||
if (mapDataFunc) {
|
||||
result = data?.content.map(mapDataFunc) ?? [];
|
||||
}
|
||||
setTableData(result);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error("数据获取失败,请稍后重试");
|
||||
} finally {
|
||||
Loading.hide();
|
||||
setLoading(false);
|
||||
// 清除轮询定时器
|
||||
const clearPollingTimer = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (extraParams = {}, skipPollingRestart = false) => {
|
||||
const { keyword, filter, current, pageSize } = searchParams;
|
||||
|
||||
if (!skipPollingRestart) {
|
||||
Loading.show();
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
// 如果正在轮询且不是轮询触发的调用,先停止当前轮询
|
||||
const wasPolling = isPolling && !skipPollingRestart;
|
||||
if (wasPolling) {
|
||||
clearPollingTimer();
|
||||
}
|
||||
|
||||
try {
|
||||
// 同时执行主要数据获取和额外的轮询函数
|
||||
const promises = [
|
||||
fetchFunc({
|
||||
...filter,
|
||||
...extraParams,
|
||||
keyword,
|
||||
type: getFirstOfArray(filter?.type) || undefined,
|
||||
status: getFirstOfArray(filter?.status) || undefined,
|
||||
tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
|
||||
page: current - 1,
|
||||
size: pageSize,
|
||||
}),
|
||||
...additionalPollingFuncs.map((func) => func()),
|
||||
];
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const { data } = results[0]; // 主要数据结果
|
||||
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
total: data?.totalElements || 0,
|
||||
}));
|
||||
let result = [];
|
||||
if (mapDataFunc) {
|
||||
result = data?.content.map(mapDataFunc) ?? [];
|
||||
}
|
||||
setTableData(result);
|
||||
|
||||
// 如果之前正在轮询且不是轮询触发的调用,重新开始轮询
|
||||
if (wasPolling) {
|
||||
const poll = () => {
|
||||
pollingTimerRef.current = setTimeout(() => {
|
||||
fetchData({}, true).then(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}, pollingInterval);
|
||||
};
|
||||
poll();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("数据获取失败,请稍后重试");
|
||||
} finally {
|
||||
Loading.hide();
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
searchParams,
|
||||
fetchFunc,
|
||||
mapDataFunc,
|
||||
isPolling,
|
||||
clearPollingTimer,
|
||||
pollingInterval,
|
||||
message,
|
||||
additionalPollingFuncs,
|
||||
]
|
||||
);
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = useCallback(() => {
|
||||
clearPollingTimer();
|
||||
setIsPolling(true);
|
||||
|
||||
const poll = () => {
|
||||
pollingTimerRef.current = setTimeout(() => {
|
||||
fetchData({}, true).then(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}, pollingInterval);
|
||||
};
|
||||
|
||||
poll();
|
||||
}, [pollingInterval, clearPollingTimer, fetchData]);
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = useCallback(() => {
|
||||
clearPollingTimer();
|
||||
setIsPolling(false);
|
||||
}, [clearPollingTimer]);
|
||||
|
||||
// 搜索参数变化时,自动刷新数据
|
||||
// keyword 变化时,防抖500ms后刷新
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
fetchData();
|
||||
@@ -96,6 +196,16 @@ export default function useFetchData<T>(
|
||||
searchParams?.keyword ? 500 : 0
|
||||
);
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
startPolling();
|
||||
}
|
||||
return () => {
|
||||
clearPollingTimer();
|
||||
};
|
||||
}, [clearPollingTimer]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
tableData,
|
||||
@@ -109,5 +219,8 @@ export default function useFetchData<T>(
|
||||
setPagination,
|
||||
handleFiltersChange,
|
||||
fetchData,
|
||||
isPolling,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
PictureOutlined,
|
||||
CalculatorOutlined,
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { FileImage, FileText, Music, Repeat, Video } from "lucide-react";
|
||||
|
||||
// 模板类型选项
|
||||
export const templateTypes = [
|
||||
{
|
||||
value: "text",
|
||||
label: "文本",
|
||||
icon: FileText,
|
||||
description: "处理文本数据的清洗模板",
|
||||
},
|
||||
{
|
||||
value: "image",
|
||||
label: "图片",
|
||||
icon: FileImage,
|
||||
description: "处理图像数据的清洗模板",
|
||||
},
|
||||
{
|
||||
value: "video",
|
||||
label: "视频",
|
||||
icon: Video,
|
||||
description: "处理视频数据的清洗模板",
|
||||
},
|
||||
{
|
||||
value: "audio",
|
||||
label: "音频",
|
||||
icon: Music,
|
||||
description: "处理音频数据的清洗模板",
|
||||
},
|
||||
{
|
||||
value: "image-to-text",
|
||||
label: "图片转文本",
|
||||
icon: Repeat,
|
||||
description: "图像识别转文本的处理模板",
|
||||
},
|
||||
];
|
||||
|
||||
// 算子分类
|
||||
export const OPERATOR_CATEGORIES = {
|
||||
data: { name: "数据清洗", icon: <DatabaseOutlined />, color: "#1677ff" },
|
||||
ml: { name: "机器学习", icon: <ThunderboltOutlined />, color: "#722ed1" },
|
||||
vision: { name: "计算机视觉", icon: <PictureOutlined />, color: "#52c41a" },
|
||||
nlp: { name: "自然语言处理", icon: <FileTextOutlined />, color: "#faad14" },
|
||||
analysis: { name: "数据分析", icon: <BarChartOutlined />, color: "#f5222d" },
|
||||
transform: { name: "数据转换", icon: <SwapOutlined />, color: "#13c2c2" },
|
||||
io: { name: "输入输出", icon: <FileTextOutlined />, color: "#595959" },
|
||||
math: { name: "数学计算", icon: <CalculatorOutlined />, color: "#fadb14" },
|
||||
};
|
||||
@@ -93,9 +93,13 @@ function cleaningTaskItem() {
|
||||
srcDatasetName: Mock.Random.ctitle(5, 15),
|
||||
destDatasetId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""),
|
||||
destDatasetName: Mock.Random.ctitle(5, 15),
|
||||
progress: Mock.Random.float(0, 100, 2, 2),
|
||||
progress: {
|
||||
finishedFileNum: Mock.Random.integer(0, 100),
|
||||
process: Mock.Random.integer(0, 100),
|
||||
totalFileNum: 100,
|
||||
},
|
||||
startedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
|
||||
endedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
|
||||
finishedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
|
||||
createdAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
|
||||
updatedAt: Mock.Random.datetime("yyyy-MM-dd HH:mm:ss"),
|
||||
instance: operatorList,
|
||||
@@ -244,7 +248,7 @@ module.exports = function (router) {
|
||||
const task = cleaningTaskList.find((j) => j.id === taskId);
|
||||
|
||||
if (task) {
|
||||
task.status = "running";
|
||||
task.status = "RUNNING";
|
||||
task.startTime = new Date().toISOString();
|
||||
|
||||
res.send({
|
||||
@@ -252,7 +256,7 @@ module.exports = function (router) {
|
||||
msg: "Cleaning task execution started",
|
||||
data: {
|
||||
executionId: Mock.Random.guid().replace(/[^a-zA-Z0-9]/g, ""),
|
||||
status: "running",
|
||||
status: "RUNNING",
|
||||
message: "Task execution started successfully",
|
||||
},
|
||||
});
|
||||
@@ -271,7 +275,7 @@ module.exports = function (router) {
|
||||
const task = cleaningTaskList.find((j) => j.id === taskId);
|
||||
|
||||
if (task) {
|
||||
task.status = "pending";
|
||||
task.status = "PENDING";
|
||||
task.endTime = new Date().toISOString();
|
||||
|
||||
res.send({
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
/* PreciseDragDrop.css */
|
||||
.precise-drag-drop {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.containers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.container.drag-over {
|
||||
border-color: #3498db;
|
||||
background-color: #f8fafc;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.container-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.items-list {
|
||||
padding: 20px;
|
||||
min-height: 500px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--item-color);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.item.dragging {
|
||||
opacity: 0.6;
|
||||
cursor: grabbing;
|
||||
transform: rotate(3deg) scale(1.05);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.item.drag-over.insert-above {
|
||||
border-top: 2px dashed var(--item-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.item.drag-over.insert-below {
|
||||
border-bottom: 2px dashed var(--item-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-index {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: var(--item-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.priority-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
background: #f1f3f4;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-transform: capitalize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #bdc3c7;
|
||||
font-size: 16px;
|
||||
cursor: grab;
|
||||
padding: 8px;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: #7f8c8d;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 插入位置指示器 */
|
||||
.insert-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.insert-indicator.above {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.insert-indicator.below {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.indicator-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--item-color, #3498db), transparent);
|
||||
}
|
||||
|
||||
.indicator-arrow {
|
||||
color: var(--item-color, #3498db);
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #f8f9fa;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.instruction-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.instruction:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.instruction .icon {
|
||||
font-size: 1.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instruction strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.instruction p {
|
||||
margin: 0;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.items-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.items-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.items-list::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.items-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.containers {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.precise-drag-drop {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.instruction-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.container-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import "./DragDrop.css";
|
||||
|
||||
const PreciseDragDrop = () => {
|
||||
// 初始数据
|
||||
const [leftItems, setLeftItems] = useState([
|
||||
{
|
||||
id: 1,
|
||||
title: "需求分析",
|
||||
type: "analysis",
|
||||
color: "#4CAF50",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "UI设计",
|
||||
type: "design",
|
||||
color: "#2196F3",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "前端开发",
|
||||
type: "development",
|
||||
color: "#FF9800",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "后端开发",
|
||||
type: "development",
|
||||
color: "#9C27B0",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "功能测试",
|
||||
type: "testing",
|
||||
color: "#3F51B5",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "部署上线",
|
||||
type: "deployment",
|
||||
color: "#009688",
|
||||
priority: "low",
|
||||
},
|
||||
]);
|
||||
|
||||
const [rightItems, setRightItems] = useState([
|
||||
{
|
||||
id: 7,
|
||||
title: "项目启动",
|
||||
type: "planning",
|
||||
color: "#E91E63",
|
||||
priority: "high",
|
||||
},
|
||||
]);
|
||||
|
||||
const [draggingItem, setDraggingItem] = useState(null);
|
||||
const [insertPosition, setInsertPosition] = useState(null); // 'above' 或 'below'
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleDragStart = (e, item, source) => {
|
||||
setDraggingItem({ ...item, source });
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
|
||||
setTimeout(() => {
|
||||
e.target.classList.add("dragging");
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = (e) => {
|
||||
setDraggingItem(null);
|
||||
setInsertPosition(null);
|
||||
e.target.classList.remove("dragging");
|
||||
};
|
||||
|
||||
// 处理容器拖拽经过
|
||||
const handleContainerDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// 处理容器拖拽离开
|
||||
const handleContainerDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setInsertPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理项目拖拽经过(用于精确插入)
|
||||
const handleItemDragOver = (e, itemId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mouseY = e.clientY;
|
||||
const elementMiddle = rect.top + rect.height;
|
||||
|
||||
// 判断鼠标在元素的上半部分还是下半部分
|
||||
const newPosition = mouseY < elementMiddle ? "above" : "below";
|
||||
|
||||
setInsertPosition(newPosition);
|
||||
};
|
||||
|
||||
// 处理项目拖拽离开
|
||||
const handleItemDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setInsertPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理放置到右侧容器空白区域
|
||||
const handleDropToRightContainer = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggingItem) return;
|
||||
|
||||
// 如果是从左侧拖拽过来的
|
||||
if (draggingItem.source === "left") {
|
||||
// 检查是否已存在
|
||||
const exists = rightItems.some((item) => item.id === draggingItem.id);
|
||||
if (!exists) {
|
||||
setRightItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...draggingItem,
|
||||
source: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
setLeftItems((prev) =>
|
||||
prev.filter((item) => item.id !== draggingItem.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 处理放置到右侧容器的特定位置
|
||||
const handleDropToRightItem = (e, targetItemId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!draggingItem) return;
|
||||
|
||||
// 从左侧拖拽到右侧的精确插入
|
||||
if (draggingItem.source === "left") {
|
||||
const targetIndex = rightItems.findIndex(
|
||||
(item) => item.id === targetItemId
|
||||
);
|
||||
|
||||
if (targetIndex !== -1) {
|
||||
const insertIndex =
|
||||
insertPosition === "above" ? targetIndex : targetIndex + 1;
|
||||
|
||||
// 检查是否已存在
|
||||
const exists = rightItems.some((item) => item.id === draggingItem.id);
|
||||
if (!exists) {
|
||||
const newRightItems = [...rightItems];
|
||||
newRightItems.splice(insertIndex, 0, {
|
||||
...draggingItem,
|
||||
source: "right",
|
||||
});
|
||||
|
||||
setRightItems(newRightItems);
|
||||
setLeftItems((prev) =>
|
||||
prev.filter((item) => item.id !== draggingItem.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 右侧容器内的重新排序
|
||||
else if (draggingItem.source === "right") {
|
||||
const draggedIndex = rightItems.findIndex(
|
||||
(item) => item.id === draggingItem.id
|
||||
);
|
||||
const targetIndex = rightItems.findIndex(
|
||||
(item) => item.id === targetItemId
|
||||
);
|
||||
|
||||
if (
|
||||
draggedIndex !== -1 &&
|
||||
targetIndex !== -1 &&
|
||||
draggedIndex !== targetIndex
|
||||
) {
|
||||
const newItems = [...rightItems];
|
||||
const [draggedItem] = newItems.splice(draggedIndex, 1);
|
||||
|
||||
// 计算正确的插入位置
|
||||
let insertIndex =
|
||||
insertPosition === "above" ? targetIndex : targetIndex + 1;
|
||||
if (draggedIndex < insertIndex) {
|
||||
insertIndex--; // 调整插入位置,因为已经移除了原元素
|
||||
}
|
||||
|
||||
newItems.splice(insertIndex, 0, draggedItem);
|
||||
setRightItems(newItems);
|
||||
}
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 处理拖拽回左侧容器
|
||||
const handleDropToLeft = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggingItem || draggingItem.source !== "right") return;
|
||||
|
||||
setRightItems((prev) => prev.filter((item) => item.id !== draggingItem.id));
|
||||
setLeftItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...draggingItem,
|
||||
source: "left",
|
||||
},
|
||||
]);
|
||||
|
||||
resetDragState();
|
||||
};
|
||||
|
||||
// 重置拖拽状态
|
||||
const resetDragState = () => {
|
||||
setDraggingItem(null);
|
||||
setInsertPosition(null);
|
||||
};
|
||||
|
||||
// 清空右侧容器
|
||||
const clearRightContainer = () => {
|
||||
setLeftItems((prev) => [
|
||||
...prev,
|
||||
...rightItems.map((item) => ({
|
||||
...item,
|
||||
source: "left",
|
||||
})),
|
||||
]);
|
||||
setRightItems([]);
|
||||
};
|
||||
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case "analysis":
|
||||
return "📊";
|
||||
case "design":
|
||||
return "🎨";
|
||||
case "development":
|
||||
return "💻";
|
||||
case "testing":
|
||||
return "🧪";
|
||||
case "deployment":
|
||||
return "🚀";
|
||||
case "planning":
|
||||
return "📋";
|
||||
default:
|
||||
return "📌";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级标签
|
||||
const getPriorityLabel = (priority) => {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return { label: "高优先级", class: "priority-high" };
|
||||
case "medium":
|
||||
return { label: "中优先级", class: "priority-medium" };
|
||||
case "low":
|
||||
return { label: "低优先级", class: "priority-low" };
|
||||
default:
|
||||
return { label: "普通", class: "priority-medium" };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="precise-drag-drop">
|
||||
<div className="header">
|
||||
<h1>精确位置拖拽排序</h1>
|
||||
<p>拖拽时悬停在项目上方或下方可选择精确插入位置</p>
|
||||
</div>
|
||||
|
||||
<div className="containers">
|
||||
{/* 左侧容器 - 待办事项 */}
|
||||
<div
|
||||
className={`container left-container `}
|
||||
onDragOver={(e) => handleContainerDragOver(e, "left")}
|
||||
onDragLeave={handleContainerDragLeave}
|
||||
onDrop={handleDropToLeft}
|
||||
>
|
||||
<div className="container-header">
|
||||
<h2>📋 待办事项</h2>
|
||||
<span className="count">{leftItems.length} 项</span>
|
||||
</div>
|
||||
<div className="items-list">
|
||||
{leftItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="item"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, "left")}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{ "--item-color": item.color }}
|
||||
>
|
||||
<div className="item-content">
|
||||
<span className="item-icon">{getTypeIcon(item.type)}</span>
|
||||
<div className="item-info">
|
||||
<span className="item-title">{item.title}</span>
|
||||
<span
|
||||
className={`priority-tag ${
|
||||
getPriorityLabel(item.priority).class
|
||||
}`}
|
||||
>
|
||||
{getPriorityLabel(item.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-type">{item.type}</div>
|
||||
</div>
|
||||
))}
|
||||
{leftItems.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>🎉 所有任务已完成!</p>
|
||||
<span>从右侧拖拽项目回来重新安排</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧容器 - 进行中的任务 */}
|
||||
<div
|
||||
className={`container right-container`}
|
||||
onDragOver={(e) => handleContainerDragOver(e, "right")}
|
||||
onDragLeave={handleContainerDragLeave}
|
||||
onDrop={handleDropToRightContainer}
|
||||
>
|
||||
<div className="container-header">
|
||||
<h2>🚀 进行中的任务</h2>
|
||||
<div className="header-actions">
|
||||
<span className="count">{rightItems.length} 项</span>
|
||||
{rightItems.length > 0 && (
|
||||
<button className="clear-btn" onClick={clearRightContainer}>
|
||||
清空所有
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="items-list">
|
||||
{rightItems.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>📥 暂无进行中的任务</p>
|
||||
<span>从左侧拖拽项目过来开始工作</span>
|
||||
</div>
|
||||
) : (
|
||||
rightItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`item `}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item, "right")}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
||||
onDragLeave={handleItemDragLeave}
|
||||
onDrop={(e) => handleDropToRightItem(e, item.id)}
|
||||
style={{ "--item-color": item.color }}
|
||||
>
|
||||
<div className="item-content">
|
||||
<span className="item-index">{index + 1}</span>
|
||||
<span className="item-icon">{getTypeIcon(item.type)}</span>
|
||||
<div className="item-info">
|
||||
<span className="item-title">{item.title}</span>
|
||||
<span
|
||||
className={`priority-tag ${
|
||||
getPriorityLabel(item.priority).class
|
||||
}`}
|
||||
>
|
||||
{getPriorityLabel(item.priority).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<span className="drag-handle">⋮⋮</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="instructions">
|
||||
<h3>🎯 操作指南</h3>
|
||||
<div className="instruction-grid">
|
||||
<div className="instruction">
|
||||
<span className="icon">🎯</span>
|
||||
<div>
|
||||
<strong>精确插入</strong>
|
||||
<p>拖拽时悬停在项目上方或下方选择插入位置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction">
|
||||
<span className="icon">🔄</span>
|
||||
<div>
|
||||
<strong>重新排序</strong>
|
||||
<p>在右侧容器内拖拽调整任务顺序</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction">
|
||||
<span className="icon">📤</span>
|
||||
<div>
|
||||
<strong>移回待办</strong>
|
||||
<p>从右侧拖拽任务回左侧容器</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="instruction">
|
||||
<span className="icon">🧹</span>
|
||||
<div>
|
||||
<strong>批量操作</strong>
|
||||
<p>使用"清空所有"按钮快速重置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreciseDragDrop;
|
||||
@@ -1,10 +1,6 @@
|
||||
import RadioCard from "@/components/RadioCard";
|
||||
import { queryDatasetsUsingGet } from "@/pages/DataManagement/dataset.api";
|
||||
import {
|
||||
datasetTypeMap,
|
||||
datasetTypes,
|
||||
mapDataset,
|
||||
} from "@/pages/DataManagement/dataset.const";
|
||||
import { datasetTypes, mapDataset } from "@/pages/DataManagement/dataset.const";
|
||||
import {
|
||||
Dataset,
|
||||
DatasetSubType,
|
||||
@@ -12,8 +8,7 @@ import {
|
||||
} from "@/pages/DataManagement/dataset.model";
|
||||
import { Input, Select, Form } from "antd";
|
||||
import TextArea from "antd/es/input/TextArea";
|
||||
import { Database } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function CreateTaskStepOne({
|
||||
form,
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function TaskList() {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const taskOperations = (record) => {
|
||||
const taskOperations = (record: CleansingTask) => {
|
||||
const isRunning = record.status?.value === TaskStatus.RUNNING;
|
||||
const showStart = [
|
||||
TaskStatus.PENDING,
|
||||
@@ -91,7 +91,8 @@ export default function TaskList() {
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
|
||||
danger: true,
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: deleteTask, // implement delete logic
|
||||
},
|
||||
];
|
||||
@@ -104,12 +105,21 @@ export default function TaskList() {
|
||||
key: "name",
|
||||
fixed: "left",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "源数据集",
|
||||
dataIndex: "srcDatasetId",
|
||||
key: "srcDatasetId",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, record: CleansingTask) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -128,6 +138,7 @@ export default function TaskList() {
|
||||
dataIndex: "destDatasetId",
|
||||
key: "destDatasetId",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, record: CleansingTask) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -147,47 +158,68 @@ export default function TaskList() {
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (status: any) => {
|
||||
return <Badge color={status.color} text={status.label} />;
|
||||
return <Badge color={status?.color} text={status?.label} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "开始时间",
|
||||
dataIndex: "startedAt",
|
||||
key: "startedAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "结束时间",
|
||||
dataIndex: "finishedAt",
|
||||
key: "finishedAt",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "进度",
|
||||
dataIndex: "progress",
|
||||
key: "progress",
|
||||
dataIndex: "process",
|
||||
key: "process",
|
||||
width: 200,
|
||||
render: (progress: number) => (
|
||||
<Progress percent={progress} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
title: "已处理文件数",
|
||||
dataIndex: "finishedFileNum",
|
||||
key: "finishedFileNum",
|
||||
width: 150,
|
||||
align: "right",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "总文件数",
|
||||
dataIndex: "totalFileNum",
|
||||
key: "totalFileNum",
|
||||
width: 150,
|
||||
align: "right",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "执行耗时",
|
||||
dataIndex: "duration",
|
||||
key: "duration",
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "开始时间",
|
||||
dataIndex: "startedAt",
|
||||
key: "startedAt",
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "结束时间",
|
||||
dataIndex: "finishedAt",
|
||||
key: "finishedAt",
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
key: "createdAt",
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "数据量变化",
|
||||
dataIndex: "dataSizeChange",
|
||||
key: "dataSizeChange",
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_: any, record: CleansingTask) => {
|
||||
if (record.before !== undefined && record.after !== undefined) {
|
||||
return `${record.before} → ${record.after}`;
|
||||
@@ -207,6 +239,7 @@ export default function TaskList() {
|
||||
<Button
|
||||
type="text"
|
||||
icon={op.icon}
|
||||
danger={op?.danger}
|
||||
onClick={() => op.onClick(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -232,6 +265,7 @@ export default function TaskList() {
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle={true}
|
||||
onReload={fetchData}
|
||||
onClearFilters={() => setSearchParams({ ...searchParams, filter: {} })}
|
||||
/>
|
||||
{/* Task List */}
|
||||
{viewMode === "card" ? (
|
||||
|
||||
@@ -31,7 +31,8 @@ export default function TemplateList() {
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除模板",
|
||||
icon: <DeleteOutlined style={{ color: "#f5222d" }} />,
|
||||
danger: true,
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: (template: CleansingTemplate) => deleteTemplate(template), // 可实现删除逻辑
|
||||
},
|
||||
];
|
||||
|
||||
@@ -95,6 +95,7 @@ export const mapTask = (task: CleansingTask) => {
|
||||
const createdAt = formatDateTime(task.createdAt);
|
||||
return {
|
||||
...task,
|
||||
...task.progress,
|
||||
createdAt,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
@@ -105,18 +106,18 @@ export const mapTask = (task: CleansingTask) => {
|
||||
before,
|
||||
after,
|
||||
statistics: [
|
||||
{ label: "进度", value: `${task.progress || 0}%` },
|
||||
{ label: "进度", value: `${task?.progress?.process || 0}%` },
|
||||
{
|
||||
label: "执行耗时",
|
||||
value: duration,
|
||||
},
|
||||
{
|
||||
label: "处理前数据大小",
|
||||
value: task.beforeSize ? formatBytes(task.beforeSize) : "--",
|
||||
label: "已处理文件数",
|
||||
value: task?.progress?.finishedFileNum || 0,
|
||||
},
|
||||
{
|
||||
label: "处理后数据大小",
|
||||
value: task.afterSize ? formatBytes(task.afterSize) : "--",
|
||||
label: "总文件数",
|
||||
value: task?.progress?.totalFileNum || 0,
|
||||
},
|
||||
],
|
||||
lastModified: formatDateTime(task.createdAt),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { Button, Form, App } from "antd";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { createDatasetUsingPost } from "../dataset.api";
|
||||
import { DatasetType, DataSource } from "../dataset.model";
|
||||
import { DatasetType } from "../dataset.model";
|
||||
import BasicInformation from "./components/BasicInformation";
|
||||
|
||||
export default function DatasetCreate() {
|
||||
@@ -27,9 +27,9 @@ export default function DatasetCreate() {
|
||||
files: undefined,
|
||||
};
|
||||
try {
|
||||
await createDatasetUsingPost(params);
|
||||
const { data } = await createDatasetUsingPost(params);
|
||||
message.success(`数据集创建成功`);
|
||||
navigate("/data/management");
|
||||
navigate("/data/management/detail/" + data.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("数据集创建失败,请重试");
|
||||
@@ -69,7 +69,11 @@ export default function DatasetCreate() {
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end p-6 border-top">
|
||||
<Button onClick={() => navigate("/data/management")}>取消</Button>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!newDataset.name || !newDataset.datasetType}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "../dataset.api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dataset, DatasetType } from "../dataset.model";
|
||||
import { App, Button, Drawer, Form, Modal } from "antd";
|
||||
import { App, Button, Form, Modal } from "antd";
|
||||
|
||||
export default function EditDataset({
|
||||
open,
|
||||
@@ -16,7 +16,7 @@ export default function EditDataset({
|
||||
open: boolean;
|
||||
data: Dataset | null;
|
||||
onClose: () => void;
|
||||
onRefresh?: () => void;
|
||||
onRefresh?: (showMessage?: boolean) => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const { message } = App.useApp();
|
||||
@@ -60,7 +60,7 @@ export default function EditDataset({
|
||||
await updateDatasetByIdUsingPut(data?.id, params);
|
||||
onClose();
|
||||
message.success("数据集更新成功");
|
||||
onRefresh?.();
|
||||
onRefresh?.(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error("数据集更新失败,请重试");
|
||||
|
||||
@@ -5,15 +5,17 @@ import {
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import DetailHeader from "@/components/DetailHeader";
|
||||
import { mapDataset, datasetTypeMap } from "../dataset.const";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useFilesOperation } from "../hooks";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { useFilesOperation } from "./useFilesOperation";
|
||||
import {
|
||||
createDatasetTagUsingPost,
|
||||
downloadFile,
|
||||
deleteDatasetByIdUsingDelete,
|
||||
downloadDatasetUsingGet,
|
||||
queryDatasetByIdUsingGet,
|
||||
queryDatasetTagsUsingGet,
|
||||
updateDatasetByIdUsingPut,
|
||||
@@ -42,6 +44,7 @@ const tabList = [
|
||||
|
||||
export default function DatasetDetail() {
|
||||
const { id } = useParams(); // 获取动态路由参数
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const { message } = App.useApp();
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
@@ -77,11 +80,17 @@ export default function DatasetDetail() {
|
||||
if (showMessage) message.success({ content: "数据刷新成功" });
|
||||
};
|
||||
|
||||
const handleExportFormat = async ({ type }) => {
|
||||
await downloadFile(dataset.id, type, `${dataset.name}-${type}.zip`);
|
||||
const handleDownload = async () => {
|
||||
await downloadDatasetUsingGet(dataset.id);
|
||||
message.success("文件下载成功");
|
||||
};
|
||||
|
||||
const handleDeleteDataset = async () => {
|
||||
await deleteDatasetByIdUsingDelete(dataset.id);
|
||||
navigate("/data/management");
|
||||
message.success("数据集删除成功");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const refreshDataset = () => {
|
||||
fetchDataset();
|
||||
@@ -153,7 +162,7 @@ export default function DatasetDetail() {
|
||||
// { key: "csv", label: "CSV 格式", icon: <FileTextOutlined /> },
|
||||
// { key: "coco", label: "COCO 格式", icon: <FileImageOutlined /> },
|
||||
// ],
|
||||
onMenuClick: handleExportFormat,
|
||||
onClick: () => handleDownload(),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
@@ -161,6 +170,20 @@ export default function DatasetDetail() {
|
||||
icon: <ReloadOutlined />,
|
||||
onClick: handleRefresh,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
confirm: {
|
||||
title: "确认删除该数据集?",
|
||||
description: "删除后该数据集将无法恢复,请谨慎操作。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
},
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: handleDeleteDataset,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Select, Input, Form, Radio, Modal, Button } from "antd";
|
||||
import {
|
||||
Select,
|
||||
Input,
|
||||
Form,
|
||||
Radio,
|
||||
Modal,
|
||||
Button,
|
||||
App,
|
||||
UploadFile,
|
||||
} from "antd";
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
import { dataSourceOptions } from "../../dataset.const";
|
||||
import { Dataset, DataSource } from "../../dataset.model";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { queryTasksUsingGet } from "@/pages/DataCollection/collection.apis";
|
||||
import { useImportFile } from "../../hooks";
|
||||
import { updateDatasetByIdUsingPut } from "../../dataset.api";
|
||||
import { sliceFile } from "@/utils/file.util";
|
||||
import Dragger from "antd/es/upload/Dragger";
|
||||
|
||||
export default function ImportConfiguration({
|
||||
data,
|
||||
@@ -15,16 +26,52 @@ export default function ImportConfiguration({
|
||||
data?: Dataset;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh?: () => void;
|
||||
onRefresh?: (showMessage?: boolean) => void;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [collectionOptions, setCollectionOptions] = useState([]);
|
||||
const [importConfig, setImportConfig] = useState<any>({
|
||||
source: DataSource.UPLOAD,
|
||||
});
|
||||
const { importFileRender, handleUpload } = useImportFile();
|
||||
|
||||
// 获取归集任务列表
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const fileSliceList = useMemo(() => {
|
||||
const sliceList = fileList.map((file) => {
|
||||
const slices = sliceFile(file);
|
||||
return { originFile: file, slices, name: file.name, size: file.size };
|
||||
});
|
||||
return sliceList;
|
||||
}, [fileList]);
|
||||
|
||||
// 本地上传文件相关逻辑
|
||||
|
||||
const resetFiles = () => {
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const handleUpload = async (dataset: Dataset) => {
|
||||
const formData = new FormData();
|
||||
fileList.forEach((file) => {
|
||||
formData.append("file", file);
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("upload:dataset", {
|
||||
detail: { dataset, files: fileSliceList },
|
||||
})
|
||||
);
|
||||
resetFiles();
|
||||
};
|
||||
|
||||
const handleBeforeUpload = (_, files: UploadFile[]) => {
|
||||
setFileList([...fileList, ...files]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: UploadFile) => {
|
||||
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
|
||||
};
|
||||
|
||||
const fetchCollectionTasks = async () => {
|
||||
try {
|
||||
const res = await queryTasksUsingGet({ page: 0, size: 100 });
|
||||
@@ -40,6 +87,8 @@ export default function ImportConfiguration({
|
||||
|
||||
const resetState = () => {
|
||||
form.resetFields();
|
||||
setFileList([]);
|
||||
form.setFieldsValue({ files: null });
|
||||
setImportConfig({ source: DataSource.UPLOAD });
|
||||
};
|
||||
|
||||
@@ -51,13 +100,16 @@ export default function ImportConfiguration({
|
||||
...importConfig,
|
||||
});
|
||||
}
|
||||
resetState();
|
||||
onRefresh?.();
|
||||
message.success("数据已更新");
|
||||
onRefresh?.(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchCollectionTasks();
|
||||
if (open) {
|
||||
resetState();
|
||||
fetchCollectionTasks();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
@@ -65,12 +117,19 @@ export default function ImportConfiguration({
|
||||
title="导入数据"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={onClose}
|
||||
onCancel={() => {
|
||||
onClose();
|
||||
resetState();
|
||||
}}
|
||||
maskClosable={false}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={handleImportData}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!fileList?.length && !importConfig.dataSource}
|
||||
onClick={handleImportData}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</>
|
||||
@@ -132,6 +191,7 @@ export default function ImportConfiguration({
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* obs import */}
|
||||
{importConfig?.source === DataSource.OBS && (
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-blue-50 rounded-lg">
|
||||
@@ -185,7 +245,18 @@ export default function ImportConfiguration({
|
||||
},
|
||||
]}
|
||||
>
|
||||
{importFileRender()}
|
||||
<Dragger
|
||||
className="w-full"
|
||||
onRemove={handleRemoveFile}
|
||||
beforeUpload={handleBeforeUpload}
|
||||
multiple
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">本地文件上传</p>
|
||||
<p className="ant-upload-hint">拖拽文件到此处或点击选择文件</p>
|
||||
</Dragger>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
|
||||
@@ -82,11 +82,6 @@ export default function Overview({ dataset, filesOperation }) {
|
||||
label: "更新时间",
|
||||
children: dataset.updatedAt,
|
||||
},
|
||||
{
|
||||
key: "dataSource",
|
||||
label: "数据源",
|
||||
children: dataset.dataSource || "未知",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { App } from "antd";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
deleteDatasetFileUsingDelete,
|
||||
downloadFile,
|
||||
downloadFileByIdUsingGet,
|
||||
exportDatasetUsingPost,
|
||||
queryDatasetFilesUsingGet,
|
||||
} from "../dataset.api";
|
||||
@@ -51,7 +51,7 @@ export function useFilesOperation(dataset: Dataset) {
|
||||
const handleDownloadFile = async (file: DatasetFile) => {
|
||||
console.log("批量下载文件:", selectedFiles);
|
||||
// 实际导出逻辑
|
||||
await downloadFile(dataset.id, file.id, file.fileName);
|
||||
await downloadFileByIdUsingGet(dataset.id, file.id, file.fileName);
|
||||
// 假设导出成功
|
||||
message.success({
|
||||
content: `已导出 1 个文件`,
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import TagManager from "@/components/TagManagement";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from "../dataset.api";
|
||||
import { formatBytes } from "@/utils/unit";
|
||||
import EditDataset from "../Create/EditDataset";
|
||||
import ImportConfiguration from "../Detail/components/ImportConfiguration";
|
||||
|
||||
export default function DatasetManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -32,7 +34,7 @@ export default function DatasetManagementPage() {
|
||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||
const [editDatasetOpen, setEditDatasetOpen] = useState(false);
|
||||
const [currentDataset, setCurrentDataset] = useState<Dataset | null>(null);
|
||||
|
||||
const [showUploadDialog, setShowUploadDialog] = useState(false);
|
||||
const [statisticsData, setStatisticsData] = useState<any>({
|
||||
count: {},
|
||||
size: {},
|
||||
@@ -117,7 +119,13 @@ export default function DatasetManagementPage() {
|
||||
fetchData,
|
||||
setSearchParams,
|
||||
handleFiltersChange,
|
||||
} = useFetchData(queryDatasetsUsingGet, mapDataset);
|
||||
} = useFetchData<Dataset>(
|
||||
queryDatasetsUsingGet,
|
||||
mapDataset,
|
||||
30000, // 30秒轮询间隔
|
||||
true, // 自动刷新
|
||||
[fetchStatistics] // 额外的轮询函数
|
||||
);
|
||||
|
||||
const handleDownloadDataset = async (dataset: Dataset) => {
|
||||
await downloadDatasetUsingGet(dataset.id, dataset.name);
|
||||
@@ -131,9 +139,17 @@ export default function DatasetManagementPage() {
|
||||
message.success("数据删除成功");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics();
|
||||
}, []);
|
||||
const handleImportData = (dataset: Dataset) => {
|
||||
setCurrentDataset(dataset);
|
||||
setShowUploadDialog(true);
|
||||
};
|
||||
|
||||
const handleRefresh = async (showMessage = true) => {
|
||||
await fetchData();
|
||||
if (showMessage) {
|
||||
message.success("数据已刷新");
|
||||
}
|
||||
};
|
||||
|
||||
const operations = [
|
||||
{
|
||||
@@ -141,11 +157,18 @@ export default function DatasetManagementPage() {
|
||||
label: "编辑",
|
||||
icon: <EditOutlined />,
|
||||
onClick: (item: Dataset) => {
|
||||
console.log(item);
|
||||
setCurrentDataset(item);
|
||||
setEditDatasetOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "import",
|
||||
label: "导入",
|
||||
icon: <UploadOutlined />,
|
||||
onClick: (item: Dataset) => {
|
||||
handleImportData(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
label: "下载",
|
||||
@@ -158,6 +181,14 @@ export default function DatasetManagementPage() {
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
danger: true,
|
||||
confirm: {
|
||||
title: "确认删除该数据集?",
|
||||
description: "删除后该数据集将无法恢复,请谨慎操作。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
},
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: (item: Dataset) => handleDeleteDataset(item.id),
|
||||
},
|
||||
@@ -291,7 +322,7 @@ export default function DatasetManagementPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">数据管理</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* tasks */}
|
||||
<TagManager
|
||||
onCreate={createDatasetTagUsingPost}
|
||||
@@ -336,14 +367,26 @@ export default function DatasetManagementPage() {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showViewToggle
|
||||
onReload={fetchData}
|
||||
onReload={handleRefresh}
|
||||
/>
|
||||
{viewMode === "card" ? renderCardView() : renderListView()}
|
||||
<EditDataset
|
||||
open={editDatasetOpen}
|
||||
data={currentDataset}
|
||||
onClose={() => setEditDatasetOpen(false)}
|
||||
onRefresh={fetchData}
|
||||
onClose={() => {
|
||||
setCurrentDataset(null);
|
||||
setEditDatasetOpen(false);
|
||||
}}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<ImportConfiguration
|
||||
data={currentDataset}
|
||||
open={showUploadDialog}
|
||||
onClose={() => {
|
||||
setCurrentDataset(null);
|
||||
setShowUploadDialog(false);
|
||||
}}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post, put, del, download, upload } from "@/utils/request";
|
||||
import { get, post, put, del, download } from "@/utils/request";
|
||||
|
||||
// 数据集统计接口
|
||||
export function getDatasetStatisticsUsingGet() {
|
||||
@@ -35,15 +35,8 @@ export function deleteDatasetByIdUsingDelete(id: string | number) {
|
||||
}
|
||||
|
||||
// 下载数据集
|
||||
export function downloadDatasetUsingGet(
|
||||
id: string | number,
|
||||
filename?: string
|
||||
) {
|
||||
return download(
|
||||
`/api/data-management/datasets/${id}/download`,
|
||||
null,
|
||||
filename
|
||||
);
|
||||
export function downloadDatasetUsingGet(id: string | number) {
|
||||
return download(`/api/data-management/datasets/${id}/files/download`);
|
||||
}
|
||||
|
||||
// 验证数据集
|
||||
@@ -61,15 +54,15 @@ export function uploadDatasetFileUsingPost(id: string | number, data: any) {
|
||||
return post(`/api/data-management/datasets/${id}/files`, data);
|
||||
}
|
||||
|
||||
export function downloadFile(
|
||||
export function downloadFileByIdUsingGet(
|
||||
id: string | number,
|
||||
fileId: string | number,
|
||||
filename?: string
|
||||
fileName: string
|
||||
) {
|
||||
return download(
|
||||
`/api/data-management/datasets/${id}/files/download`,
|
||||
`/api/data-management/datasets/${id}/files/${fileId}/download`,
|
||||
null,
|
||||
filename
|
||||
fileName
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CloseCircleOutlined,
|
||||
FileOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { AnyObject } from "antd/es/_util/type";
|
||||
import {
|
||||
FileImage,
|
||||
FileText,
|
||||
@@ -186,7 +187,7 @@ export const datasetStatusMap = {
|
||||
|
||||
export const dataSourceMap: Record<string, { label: string; value: string }> = {
|
||||
[DataSource.UPLOAD]: { label: "本地上传", value: DataSource.UPLOAD },
|
||||
[DataSource.COLLECTION]: { label: "本地归集 ", value: DataSource.COLLECTION },
|
||||
// [DataSource.COLLECTION]: { label: "本地归集 ", value: DataSource.COLLECTION },
|
||||
// [DataSource.DATABASE]: { label: "数据库导入", value: DataSource.DATABASE },
|
||||
// [DataSource.NAS]: { label: "NAS导入", value: DataSource.NAS },
|
||||
// [DataSource.OBS]: { label: "OBS导入", value: DataSource.OBS },
|
||||
@@ -194,7 +195,7 @@ export const dataSourceMap: Record<string, { label: string; value: string }> = {
|
||||
|
||||
export const dataSourceOptions = Object.values(dataSourceMap);
|
||||
|
||||
export function mapDataset(dataset: Dataset) {
|
||||
export function mapDataset(dataset: AnyObject): Dataset {
|
||||
const { icon: IconComponent, iconColor } =
|
||||
datasetTypeMap[dataset?.datasetType] || {};
|
||||
return {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useFilesOperation } from "./useFilesOperation";
|
||||
export { useImportFile } from "./useImportFile";
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Upload, type UploadFile } from "antd";
|
||||
import { InboxOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Dataset } from "@/pages/DataManagement/dataset.model";
|
||||
import { sliceFile } from "@/utils/file.util";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
export const useImportFile = () => {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const fileSliceList = useMemo(() => {
|
||||
const sliceList = fileList.map((file) => {
|
||||
const slices = sliceFile(file);
|
||||
return { originFile: file, slices, name: file.name, size: file.size };
|
||||
});
|
||||
return sliceList;
|
||||
}, [fileList]);
|
||||
|
||||
const resetFiles = () => {
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
const handleUpload = async (dataset: Dataset) => {
|
||||
const formData = new FormData();
|
||||
fileList.forEach((file) => {
|
||||
formData.append("file", file);
|
||||
});
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("upload:dataset", {
|
||||
detail: { dataset, files: fileSliceList },
|
||||
})
|
||||
);
|
||||
resetFiles();
|
||||
};
|
||||
|
||||
const handleBeforeUpload = (_, files: UploadFile[]) => {
|
||||
setFileList([...fileList, ...files]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = (file: UploadFile) => {
|
||||
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
|
||||
};
|
||||
|
||||
const importFileRender = () => (
|
||||
<Dragger
|
||||
className="w-full"
|
||||
onRemove={handleRemoveFile}
|
||||
beforeUpload={handleBeforeUpload}
|
||||
multiple
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">本地文件上传</p>
|
||||
<p className="ant-upload-hint">拖拽文件到此处或点击选择文件</p>
|
||||
</Dragger>
|
||||
);
|
||||
|
||||
return { fileList, resetFiles, handleUpload, importFileRender };
|
||||
};
|
||||
@@ -71,7 +71,7 @@ const AsiderAndHeaderLayout = () => {
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">ModelEngine</span>
|
||||
<span className="text-lg font-bold text-gray-900">DataMate</span>
|
||||
</NavLink>
|
||||
)}
|
||||
<span
|
||||
|
||||
@@ -202,7 +202,7 @@ class Request {
|
||||
try {
|
||||
const errorData = await processedResponse.json();
|
||||
error.data = errorData;
|
||||
message.error(`请求失败,错误信息: ${processedResponse.statusText}`);
|
||||
// message.error(`请求失败,错误信息: ${processedResponse.statusText}`);
|
||||
} catch {
|
||||
// 忽略JSON解析错误
|
||||
}
|
||||
@@ -326,7 +326,6 @@ class Request {
|
||||
...options,
|
||||
};
|
||||
}
|
||||
console.log("post", url, config);
|
||||
return this.request(this.baseURL + url, config);
|
||||
}
|
||||
|
||||
@@ -403,7 +402,7 @@ class Request {
|
||||
* @param {string} filename - 下载文件名
|
||||
* @param {object} options - 额外的fetch选项,包括showLoading, onDownloadProgress
|
||||
*/
|
||||
async download(url, params = null, filename = "download", options = {}) {
|
||||
async download(url, params = null, filename = "", options = {}) {
|
||||
const fullURL = this.buildURL(url, params);
|
||||
|
||||
const config = {
|
||||
@@ -416,6 +415,7 @@ class Request {
|
||||
const processedConfig = await this.executeRequestInterceptors(config);
|
||||
|
||||
let blob;
|
||||
let name = filename;
|
||||
|
||||
// 如果需要下载进度监听,使用XMLHttpRequest
|
||||
if (config.onDownloadProgress) {
|
||||
@@ -431,6 +431,10 @@ class Request {
|
||||
}
|
||||
|
||||
blob = xhrResponse.xhr.response;
|
||||
name =
|
||||
name ||
|
||||
xhrResponse.headers.get("Content-Disposition")?.split("filename=")[1] ||
|
||||
"download";
|
||||
} else {
|
||||
// 使用fetch
|
||||
const response = await fetch(fullURL, processedConfig);
|
||||
@@ -446,13 +450,17 @@ class Request {
|
||||
}
|
||||
|
||||
blob = await processedResponse.blob();
|
||||
name =
|
||||
name ||
|
||||
response.headers.get("Content-Disposition")?.split("filename=")[1] ||
|
||||
`download_${Date.now()}`;
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = filename;
|
||||
link.download = filename ?? name;
|
||||
|
||||
// 添加到DOM并触发下载
|
||||
document.body.appendChild(link);
|
||||
|
||||
@@ -64,6 +64,7 @@ export function formatExecutionDuration(
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 0) return "--";
|
||||
if (seconds < 60) {
|
||||
return `${seconds} 秒`;
|
||||
} else if (seconds < 3600) {
|
||||
|
||||
Reference in New Issue
Block a user