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:
chenghh-9609
2025-10-23 16:48:42 +08:00
committed by GitHub
parent c998de2e9d
commit c52702b073
28 changed files with 715 additions and 1216 deletions

View 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;

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -128,6 +128,7 @@ const TagManager: React.FC = ({
name: tag,
});
fetchTags();
setNewTag("");
message.success("标签添加成功");
} catch (error) {
message.error("添加标签失败");

View File

@@ -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,
};
}

View File

@@ -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" },
};

View File

@@ -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({

View File

@@ -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%;
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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" ? (

View File

@@ -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), // 可实现删除逻辑
},
];

View File

@@ -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),

View File

@@ -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>

View File

@@ -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("数据集更新失败,请重试");

View File

@@ -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 (

View File

@@ -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>
)}

View File

@@ -82,11 +82,6 @@ export default function Overview({ dataset, filesOperation }) {
label: "更新时间",
children: dataset.updatedAt,
},
{
key: "dataSource",
label: "数据源",
children: dataset.dataSource || "未知",
},
{
key: "description",
label: "描述",

View File

@@ -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 个文件`,

View File

@@ -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>
);

View File

@@ -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
);
}

View File

@@ -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 {

View File

@@ -1,2 +0,0 @@
export { useFilesOperation } from "./useFilesOperation";
export { useImportFile } from "./useImportFile";

View File

@@ -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 };
};

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {