Integrated Redux for state management with auth and settings slices. (#117)

* feat: Implement DatasetFileTransfer component for file selection and management

* feat: Add pagination support to file list in Overview component

* feat: add DatasetFileTransfer and TagManagement components

- Added DatasetFileTransfer component for managing dataset files.
- Introduced TagManagement component for handling tags.
- Integrated Redux for state management with auth and settings slices.
- Updated package.json to include @reduxjs/toolkit and react-redux dependencies.
- Refactored existing components to utilize new DatasetFileTransfer and TagManagement components.
- Implemented hooks for typed dispatch and selector in Redux.
- Enhanced CreateKnowledgeBase and SynthesisTask components to support new features.
This commit is contained in:
chenghh-9609
2025-11-29 17:37:36 +08:00
committed by GitHub
parent 2e13bb9b4c
commit 5c178d5274
16 changed files with 305 additions and 22 deletions

View File

@@ -0,0 +1,269 @@
import React, { useEffect } from "react";
import { Button, Input, Table } from "antd";
import { RightOutlined } from "@ant-design/icons";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import {
Dataset,
DatasetFile,
DatasetType,
} from "@/pages/DataManagement/dataset.model";
import {
queryDatasetFilesUsingGet,
queryDatasetsUsingGet,
} from "@/pages/DataManagement/dataset.api";
import { formatBytes } from "@/utils/unit";
import { useDebouncedEffect } from "@/hooks/useDebouncedEffect";
interface DatasetFileTransferProps
extends React.HTMLAttributes<HTMLDivElement> {
open: boolean;
selectedFilesMap: { [key: string]: DatasetFile };
onSelectedFilesChange: (filesMap: { [key: string]: DatasetFile }) => void;
}
const fileCols = [
{
title: "所属数据集",
dataIndex: "datasetName",
key: "datasetName",
ellipsis: true,
},
{
title: "文件名",
dataIndex: "fileName",
key: "fileName",
ellipsis: true,
},
{
title: "大小",
dataIndex: "fileSize",
key: "fileSize",
ellipsis: true,
render: formatBytes,
},
];
// Customize Table Transfer
const DatasetFileTransfer: React.FC<DatasetFileTransferProps> = ({
open,
selectedFilesMap,
onSelectedFilesChange,
...props
}) => {
const [datasets, setDatasets] = React.useState<Dataset[]>([]);
const [datasetSearch, setDatasetSearch] = React.useState<string>("");
const [datasetPagination, setDatasetPagination] = React.useState<{
current: number;
pageSize: number;
total: number;
}>({ current: 1, pageSize: 10, total: 0 });
const [files, setFiles] = React.useState<DatasetFile[]>([]);
const [filesSearch, setFilesSearch] = React.useState<string>("");
const [filesPagination, setFilesPagination] = React.useState<{
current: number;
pageSize: number;
total: number;
}>({ current: 1, pageSize: 10, total: 0 });
const [showFiles, setShowFiles] = React.useState<boolean>(false);
const [selectedDataset, setSelectedDataset] = React.useState<Dataset | null>(
null
);
const [datasetSelections, setDatasetSelections] = React.useState<Dataset[]>(
[]
);
const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet({
page: datasetPagination.current - 1,
size: datasetPagination.pageSize,
keyword: datasetSearch,
type: DatasetType.TEXT,
});
setDatasets(data.content.map(mapDataset) || []);
setDatasetPagination((prev) => ({
...prev,
total: data.totalElements,
}));
};
useDebouncedEffect(
() => {
fetchDatasets();
},
[datasetSearch, datasetPagination.pageSize, datasetPagination.current],
300
);
const fetchFiles = async () => {
if (!selectedDataset) return;
const { data } = await queryDatasetFilesUsingGet(selectedDataset.id, {
page: filesPagination.current - 1,
size: filesPagination.pageSize,
keyword: filesSearch,
});
setFiles(
data.content.map((item) => ({
...item,
key: item.id,
datasetName: selectedDataset.name,
})) || []
);
setFilesPagination((prev) => ({
...prev,
total: data.totalElements,
}));
};
useEffect(() => {
if (selectedDataset) {
fetchFiles();
}
}, [selectedDataset]);
const toggleSelectFile = (record: DatasetFile) => {
if (!selectedFilesMap[record.id]) {
onSelectedFilesChange({
...selectedFilesMap,
[record.id]: record,
});
} else {
const newSelectedFiles = { ...selectedFilesMap };
delete newSelectedFiles[record.id];
onSelectedFilesChange(newSelectedFiles);
}
};
useEffect(() => {
if (!open) {
// 重置状态
setDatasets([]);
setDatasetSearch("");
setDatasetPagination({ current: 1, pageSize: 10, total: 0 });
setFiles([]);
setFilesSearch("");
setFilesPagination({ current: 1, pageSize: 10, total: 0 });
setShowFiles(false);
setSelectedDataset(null);
setDatasetSelections([]);
}
}, [open]);
const datasetCols = [
{
title: "数据集名称",
dataIndex: "name",
key: "name",
ellipsis: true,
},
{
title: "文件数",
dataIndex: "fileCount",
key: "fileCount",
ellipsis: true,
},
{
title: "大小",
dataIndex: "totalSize",
key: "totalSize",
ellipsis: true,
render: formatBytes,
},
];
return (
<div {...props}>
<div className="grid grid-cols-25 gap-4 w-full">
<div className="border-card flex flex-col col-span-12">
<div className="border-bottom p-2 font-bold"></div>
<div className="p-2">
<Input
placeholder="搜索数据集名称..."
value={datasetSearch}
allowClear
onChange={(e) => setDatasetSearch(e.target.value)}
/>
</div>
<Table
scroll={{ y: 400 }}
rowKey="id"
size="small"
rowClassName={(record) =>
selectedDataset?.id === record.id ? "bg-blue-100" : ""
}
onRow={(record: Dataset) => ({
onClick: () => {
setSelectedDataset(record);
if (!datasetSelections.find((d) => d.id === record.id)) {
setDatasetSelections([...datasetSelections, record]);
} else {
setDatasetSelections(
datasetSelections.filter((d) => d.id !== record.id)
);
}
},
})}
dataSource={datasets}
columns={datasetCols}
pagination={datasetPagination}
/>
</div>
<RightOutlined />
<div className="border-card flex flex-col col-span-12">
<div className="border-bottom p-2 font-bold"></div>
<div className="p-2">
<Input
placeholder="搜索文件名称..."
value={filesSearch}
onChange={(e) => setFilesSearch(e.target.value)}
/>
</div>
<Table
scroll={{ y: 400 }}
rowKey="id"
size="small"
dataSource={files}
columns={fileCols.slice(1, fileCols.length)}
pagination={filesPagination}
onRow={(record: DatasetFile) => ({
onClick: () => toggleSelectFile(record),
})}
rowSelection={{
type: "checkbox",
onSelectAll: (selected, _, changeRows) => {
const newSelectedFiles = { ...selectedFilesMap };
if (selected) {
changeRows.forEach((row) => {
newSelectedFiles[row.id] = row;
});
} else {
changeRows.forEach((row) => {
delete newSelectedFiles[row.id];
});
}
onSelectedFilesChange(newSelectedFiles);
},
selectedRowKeys: Object.keys(selectedFilesMap),
onSelect: toggleSelectFile,
}}
/>
</div>
</div>
<Button className="mt-4" onClick={() => setShowFiles(!showFiles)}>
{showFiles ? "取消预览" : "预览"}
</Button>
<div hidden={!showFiles}>
<Table
scroll={{ y: 400 }}
rowKey="id"
size="small"
dataSource={Object.values(selectedFilesMap)}
columns={fileCols}
/>
</div>
</div>
);
};
export default DatasetFileTransfer;

View File

@@ -0,0 +1,251 @@
import React, { useEffect, useState } from "react";
import { Drawer, Input, Button, App } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import { Edit, Save, TagIcon, X, Trash } from "lucide-react";
import { TagItem } from "@/pages/DataManagement/dataset.model";
interface CustomTagProps {
isEditable?: boolean;
tag: { id: number; name: string };
editingTag?: string | null;
editingTagValue?: string;
setEditingTag?: React.Dispatch<React.SetStateAction<string | null>>;
setEditingTagValue?: React.Dispatch<React.SetStateAction<string>>;
handleEditTag?: (tag: { id: number; name: string }, value: string) => void;
handleCancelEdit?: (tag: { id: number; name: string }) => void;
handleDeleteTag?: (tag: { id: number; name: string }) => void;
}
function CustomTag({
isEditable = false,
tag,
editingTag,
editingTagValue,
setEditingTag,
setEditingTagValue,
handleEditTag,
handleCancelEdit,
handleDeleteTag,
}: CustomTagProps) {
return (
<div
key={tag.id}
className="flex items-center justify-between px-4 py-2 border-card hover:bg-gray-50"
>
{editingTag?.id === tag.id ? (
<div className="flex gap-2 flex-1">
<Input
value={editingTagValue}
onChange={(e) => setEditingTagValue?.(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleEditTag?.(tag, editingTagValue);
}
if (e.key === "Escape") {
setEditingTag?.(null);
setEditingTagValue?.("");
}
}}
className="h-6 text-sm"
autoFocus
/>
<Button
onClick={() => handleEditTag(tag, editingTagValue)}
type="link"
size="small"
icon={<Save className="w-3 h-3" />}
/>
<Button
danger
type="text"
size="small"
onClick={() => handleCancelEdit?.(tag)}
icon={<X className="w-3 h-3" />}
/>
</div>
) : (
<>
<span className="text-sm">{tag.name}</span>
{isEditable && (
<div className="flex gap-1">
<Button
size="small"
type="text"
onClick={() => {
setEditingTag?.(tag);
setEditingTagValue?.(tag.name);
}}
icon={<Edit className="w-3 h-3" />}
/>
<Button
danger
type="text"
size="small"
onClick={() => handleDeleteTag?.(tag)}
icon={<Trash className="w-3 h-3" />}
/>
</div>
)}
</>
)}
</div>
);
}
const TagManager: React.FC = ({
onFetch,
onCreate,
onDelete,
onUpdate,
}: {
onFetch: () => Promise<any>;
onCreate: (tag: Pick<TagItem, "name">) => Promise<{ ok: boolean }>;
onDelete: (tagId: number) => Promise<{ ok: boolean }>;
onUpdate: (tag: TagItem) => Promise<{ ok: boolean }>;
}) => {
const [showTagManager, setShowTagManager] = useState(false);
const { message } = App.useApp();
const [tags, setTags] = useState<{ id: number; name: string }[]>([]);
const [newTag, setNewTag] = useState("");
const [editingTag, setEditingTag] = useState<string | null>(null);
const [editingTagValue, setEditingTagValue] = useState("");
// 获取标签列表
const fetchTags = async () => {
if (!onFetch) return;
try {
const { data } = await onFetch?.();
setTags(data || []);
} catch (e) {
message.error("获取标签失败");
}
};
// 添加标签
const addTag = async (tag: string) => {
try {
await onCreate?.({
name: tag,
});
fetchTags();
setNewTag("");
message.success("标签添加成功");
} catch (error) {
message.error("添加标签失败");
}
};
// 删除标签
const deleteTag = async (tag: TagItem) => {
try {
await onDelete?.(tag.id);
fetchTags();
message.success("标签删除成功");
} catch (error) {
message.error("删除标签失败");
}
};
const updateTag = async (oldTag: TagItem, newTag: string) => {
try {
await onUpdate?.({ ...oldTag, name: newTag });
fetchTags();
message.success("标签更新成功");
} catch (error) {
message.error("更新标签失败");
}
};
const handleCreateNewTag = () => {
if (newTag.trim()) {
addTag(newTag.trim());
setNewTag("");
}
};
const handleEditTag = (tag: TagItem, value: string) => {
if (value.trim()) {
updateTag(tag, value.trim());
setEditingTag(null);
setEditingTagValue("");
}
};
const handleCancelEdit = (tag: string) => {
setEditingTag(null);
setEditingTagValue("");
};
const handleDeleteTag = (tag: TagItem) => {
deleteTag(tag);
setEditingTag(null);
setEditingTagValue("");
};
useEffect(() => {
if (showTagManager) fetchTags();
}, [showTagManager]);
return (
<>
<Button
icon={<TagIcon className="w-4 h-4 mr-2" />}
onClick={() => setShowTagManager(true)}
>
</Button>
<Drawer
open={showTagManager}
onClose={() => setShowTagManager(false)}
title="标签管理"
width={500}
>
<div className="space-y-4 flex-overflow">
{/* Add New Tag */}
<div className="flex gap-2">
<Input
placeholder="输入标签名称..."
value={newTag}
allowClear
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
addTag(e.target.value);
}
}}
/>
<Button
type="primary"
onClick={handleCreateNewTag}
disabled={!newTag.trim()}
icon={<PlusOutlined />}
>
</Button>
</div>
<div className="flex-overflow">
<div className="overflow-auto grid grid-cols-2 gap-2">
{tags.map((tag) => (
<CustomTag
isEditable
key={tag.id}
tag={tag}
editingTag={editingTag}
editingTagValue={editingTagValue}
setEditingTag={setEditingTag}
setEditingTagValue={setEditingTagValue}
handleEditTag={handleEditTag}
handleCancelEdit={handleCancelEdit}
handleDeleteTag={handleDeleteTag}
/>
))}
</div>
</div>
</div>
</Drawer>
</>
);
};
export default TagManager;

View File

@@ -0,0 +1,162 @@
import { Button, Popover, Progress } from "antd";
import { Calendar, Clock, Play, Trash2, X } from "lucide-react";
interface TaskItem {
id: string;
name: string;
status: string;
progress: number;
scheduleConfig: {
type: string;
cronExpression?: string;
executionCount?: number;
maxExecutions?: number;
};
nextExecution?: string;
importConfig: {
source?: string;
};
createdAt: string;
}
export default function TaskPopover() {
const tasks: TaskItem[] = [
{
id: "1",
name: "导入客户数据",
status: "importing",
progress: 65,
scheduleConfig: {
type: "manual",
},
importConfig: {
source: "local",
},
createdAt: "2025-07-29 14:23",
},
{
id: "2",
name: "定时同步订单",
status: "waiting",
progress: 0,
scheduleConfig: {
type: "scheduled",
cronExpression: "0 0 * * *",
executionCount: 3,
maxExecutions: 10,
},
nextExecution: "2025-07-31 00:00",
importConfig: {
source: "api",
},
createdAt: "2025-07-28 09:10",
},
{
id: "3",
name: "清理历史日志",
status: "finished",
progress: 100,
scheduleConfig: {
type: "manual",
},
importConfig: {
source: "system",
},
createdAt: "2025-07-27 17:45",
},
];
return (
<Popover
placement="topLeft"
content={
<div className="w-[500px]">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900"></h3>
<Button type="text" className="h-6 w-6 p-0">
<X className="w-4 h-4 text-black-400 hover:text-gray-500" />
</Button>
</div>
<div className="p-2">
{tasks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Clock className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm"></p>
</div>
) : (
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
className="p-3 border-card hover:bg-gray-50"
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm truncate flex-1">
{task.name}
</h4>
<div className="flex items-center gap-2">
{task.status === "waiting" && (
<Button
className="h-6 w-6 p-0 text-blue-500 hover:text-blue-700"
title="立即执行"
>
<Play className="w-3 h-3" />
</Button>
)}
<Button className="h-6 w-6 p-0 text-gray-400 hover:text-red-500">
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
{task.status === "importing" && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<span></span>
<span>{Math.round(task.progress)}%</span>
</div>
<Progress percent={task.progress} />
</div>
)}
{/* Schedule Information */}
{task.scheduleConfig.type === "scheduled" && (
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
<div className="flex items-center gap-1 mb-1">
<Calendar className="w-3 h-3" />
<span className="font-medium"></span>
</div>
<div>Cron: {task.scheduleConfig.cronExpression}</div>
{task.nextExecution && (
<div>: {task.nextExecution}</div>
)}
<div>
: {task.scheduleConfig.executionCount || 0}/
{task.scheduleConfig.maxExecutions || 10}
</div>
</div>
)}
<div className="flex items-center justify-between text-xs text-gray-400">
<span>
{task.importConfig.source === "local"
? "本地上传"
: task.importConfig.source || "未知来源"}
</span>
<span>{task.createdAt}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
}
>
<Button block></Button>
</Popover>
);
}