mirror of
https://github.com/labring/FastGPT.git
synced 2026-01-28 01:10:33 +08:00
perf: workflow node past;feat: table export (#6250)
* perf: workflow node past * feat: table export * feat: table export
This commit is contained in:
@@ -6,10 +6,12 @@ description: 'FastGPT V4.14.6 更新说明'
|
||||
|
||||
## 🚀 新增内容
|
||||
|
||||
1. Markdown 表格支持导出 csv。
|
||||
|
||||
## ⚙️ 优化
|
||||
|
||||
1. 工作流触摸板移动时,遇到输入框后会被强制阻拦。
|
||||
2. 工作流粘贴节点,精确按鼠标位置粘贴。
|
||||
|
||||
## 🐛 修复
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"document/content/docs/protocol/terms.en.mdx": "2025-12-15T23:36:54+08:00",
|
||||
"document/content/docs/protocol/terms.mdx": "2025-12-15T23:36:54+08:00",
|
||||
"document/content/docs/toc.en.mdx": "2025-08-04T13:42:36+08:00",
|
||||
"document/content/docs/toc.mdx": "2026-01-09T18:25:02+08:00",
|
||||
"document/content/docs/toc.mdx": "2026-01-11T21:09:27+08:00",
|
||||
"document/content/docs/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00",
|
||||
"document/content/docs/upgrading/4-10/4101.mdx": "2025-09-08T20:07:20+08:00",
|
||||
"document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00",
|
||||
@@ -122,7 +122,8 @@
|
||||
"document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00",
|
||||
"document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00",
|
||||
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00",
|
||||
"document/content/docs/upgrading/4-14/4145.mdx": "2026-01-11T00:59:49+08:00",
|
||||
"document/content/docs/upgrading/4-14/4145.mdx": "2026-01-11T21:09:27+08:00",
|
||||
"document/content/docs/upgrading/4-14/4146.mdx": "2026-01-12T19:12:05+08:00",
|
||||
"document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00",
|
||||
"document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00",
|
||||
"document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00",
|
||||
|
||||
53
packages/web/components/common/Markdown/MarkdownTable.tsx
Normal file
53
packages/web/components/common/Markdown/MarkdownTable.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { Box, IconButton } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { exportTableToCSV } from './utils';
|
||||
import MyIcon from '../Icon';
|
||||
|
||||
type MarkdownTableProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom table component for Markdown with CSV export functionality
|
||||
*/
|
||||
const MarkdownTable = ({ children }: MarkdownTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (tableRef.current) {
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
exportTableToCSV(tableRef.current, `table-${timestamp}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={tableRef}
|
||||
as="table"
|
||||
_hover={{
|
||||
'.export-button': {
|
||||
display: 'flex'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
className="export-button"
|
||||
icon={<MyIcon name="export" w={'14px'} />}
|
||||
size={'xs'}
|
||||
display="none"
|
||||
variant={'whiteBase'}
|
||||
onClick={handleExport}
|
||||
position="absolute"
|
||||
top={1}
|
||||
right={2}
|
||||
zIndex={1}
|
||||
aria-label={''}
|
||||
/>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MarkdownTable);
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown';
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import RehypeExternalLinks from 'rehype-external-links';
|
||||
import { Box, Code as ChakraCode, Image, Link } from '@chakra-ui/react';
|
||||
import MarkdownTable from './MarkdownTable';
|
||||
|
||||
type MarkdownProps = {
|
||||
source: string;
|
||||
@@ -114,13 +115,7 @@ const Markdown = ({ source, className }: MarkdownProps) => {
|
||||
// 水平线
|
||||
hr: () => <Box as="hr" my={4} borderColor="myGray.200" />,
|
||||
// 表格
|
||||
table: ({ children }: any) => (
|
||||
<Box overflowX="auto" my={2}>
|
||||
<Box as="table" w="100%" border="1px solid" borderColor="myGray.200" borderRadius="md">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
table: MarkdownTable as any,
|
||||
thead: ({ children }: any) => (
|
||||
<Box as="thead" bg="myGray.50">
|
||||
{children}
|
||||
|
||||
75
packages/web/components/common/Markdown/utils.ts
Normal file
75
packages/web/components/common/Markdown/utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Export table data to CSV format
|
||||
* @param tableElement - HTML table element to export
|
||||
* @param filename - Name of the exported file (without extension)
|
||||
*/
|
||||
export const exportTableToCSV = (tableElement: HTMLTableElement, filename: string = 'table') => {
|
||||
const rows: string[][] = [];
|
||||
|
||||
// Extract header rows
|
||||
const thead = tableElement.querySelector('thead');
|
||||
if (thead) {
|
||||
const headerRows = thead.querySelectorAll('tr');
|
||||
headerRows.forEach((row) => {
|
||||
const cells: string[] = [];
|
||||
row.querySelectorAll('th').forEach((cell) => {
|
||||
cells.push(escapeCsvCell(cell.textContent || ''));
|
||||
});
|
||||
if (cells.length > 0) {
|
||||
rows.push(cells);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract body rows
|
||||
const tbody = tableElement.querySelector('tbody');
|
||||
if (tbody) {
|
||||
const bodyRows = tbody.querySelectorAll('tr');
|
||||
bodyRows.forEach((row) => {
|
||||
const cells: string[] = [];
|
||||
row.querySelectorAll('td').forEach((cell) => {
|
||||
cells.push(escapeCsvCell(cell.textContent || ''));
|
||||
});
|
||||
if (cells.length > 0) {
|
||||
rows.push(cells);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to CSV format
|
||||
const csvContent = rows.map((row) => row.join(',')).join('\n');
|
||||
|
||||
// Add BOM for Excel UTF-8 compatibility
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `${filename}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape special characters in CSV cell content
|
||||
* @param cell - Cell content to escape
|
||||
* @returns Escaped cell content
|
||||
*/
|
||||
const escapeCsvCell = (cell: string): string => {
|
||||
// Remove leading/trailing whitespace
|
||||
let content = cell.trim();
|
||||
|
||||
// If cell contains comma, double quote, or newline, wrap in quotes
|
||||
if (content.includes(',') || content.includes('"') || content.includes('\n')) {
|
||||
// Escape existing double quotes by doubling them
|
||||
content = content.replace(/"/g, '""');
|
||||
content = `"${content}"`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -219,6 +219,7 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown table {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.markdown table th {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Box } from '@chakra-ui/react';
|
||||
import { CodeClassNameEnum, mdTextFormat } from './utils';
|
||||
import { useCreation } from 'ahooks';
|
||||
import type { AProps } from './A';
|
||||
import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable';
|
||||
|
||||
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
|
||||
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
|
||||
@@ -57,6 +58,7 @@ const MarkdownRender = ({
|
||||
img: (props: any) => <Image {...props} alt={props.alt} chatAuthData={chatAuthData} />,
|
||||
pre: RewritePre,
|
||||
code: Code,
|
||||
table: MarkdownTable as any,
|
||||
a: (props: any) => (
|
||||
<A
|
||||
{...props}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { getNanoid } from '@fastgpt/global/common/string/tools';
|
||||
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { type Node, useKeyPress } from 'reactflow';
|
||||
import { type Node, useKeyPress, useReactFlow } from 'reactflow';
|
||||
import { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
|
||||
import { useContextSelector } from 'use-context-selector';
|
||||
import { useWorkflowUtils } from './useUtils';
|
||||
@@ -18,6 +18,7 @@ export const useKeyboard = () => {
|
||||
const getNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodes);
|
||||
const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes);
|
||||
const mouseInCanvas = useContextSelector(WorkflowUIContext, (v) => v.mouseInCanvas);
|
||||
const mousePosition = useContextSelector(WorkflowUIContext, (v) => v.mousePosition);
|
||||
|
||||
const { getMyModelList } = useSystemStore();
|
||||
const { data: myModels } = useRequest2(getMyModelList, {
|
||||
@@ -26,6 +27,7 @@ export const useKeyboard = () => {
|
||||
|
||||
const { copyData } = useCopyData();
|
||||
const { computedNewNodeName } = useWorkflowUtils();
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const isDowningCtrl = useKeyPress(['Meta', 'Control']);
|
||||
|
||||
@@ -55,43 +57,61 @@ export const useKeyboard = () => {
|
||||
|
||||
const onPaste = useCallback(async () => {
|
||||
if (hasInputtingElement()) return;
|
||||
|
||||
// Only paste if mouse is in canvas and we have mouse position
|
||||
if (!mouseInCanvas || !mousePosition) return;
|
||||
|
||||
const copyResult = await navigator.clipboard.readText();
|
||||
try {
|
||||
const parseData = JSON.parse(copyResult) as Node<FlowNodeItemType, string | undefined>[];
|
||||
// check is array
|
||||
if (!Array.isArray(parseData)) return;
|
||||
// filter workflow data
|
||||
const newNodes = parseData
|
||||
.filter(
|
||||
(item) => !!item.type && item.data?.unique !== true && item.type !== FlowNodeTypeEnum.loop
|
||||
)
|
||||
.map((item) => {
|
||||
const nodeId = getNanoid();
|
||||
item.data.inputs.forEach((input) => {
|
||||
if (input.key === 'model') {
|
||||
if (!myModels?.has(input.value)) input.value = undefined;
|
||||
}
|
||||
});
|
||||
return {
|
||||
// reset id
|
||||
...item,
|
||||
id: nodeId,
|
||||
data: {
|
||||
...item.data,
|
||||
name: computedNewNodeName({
|
||||
templateName: item.data?.name || '',
|
||||
flowNodeType: item.data?.flowNodeType || '',
|
||||
pluginId: item.data?.pluginId
|
||||
}),
|
||||
nodeId,
|
||||
parentNodeId: undefined
|
||||
},
|
||||
position: {
|
||||
x: item.position.x + 100,
|
||||
y: item.position.y + 100
|
||||
}
|
||||
};
|
||||
const filteredData = parseData.filter(
|
||||
(item) => !!item.type && item.data?.unique !== true && item.type !== FlowNodeTypeEnum.loop
|
||||
);
|
||||
|
||||
if (filteredData.length === 0) return;
|
||||
|
||||
// Convert mouse screen position to flow position
|
||||
const pasteFlowPosition = screenToFlowPosition(mousePosition);
|
||||
|
||||
// Calculate the bounding box of the original nodes
|
||||
const minX = Math.min(...filteredData.map((item) => item.position.x));
|
||||
const minY = Math.min(...filteredData.map((item) => item.position.y));
|
||||
const maxX = Math.max(...filteredData.map((item) => item.position.x));
|
||||
const maxY = Math.max(...filteredData.map((item) => item.position.y));
|
||||
const originalCenterX = (minX + maxX) / 2;
|
||||
const originalCenterY = (minY + maxY) / 2;
|
||||
|
||||
const newNodes = filteredData.map((item) => {
|
||||
const nodeId = getNanoid();
|
||||
item.data.inputs.forEach((input) => {
|
||||
if (input.key === 'model') {
|
||||
if (!myModels?.has(input.value)) input.value = undefined;
|
||||
}
|
||||
});
|
||||
return {
|
||||
// reset id
|
||||
...item,
|
||||
id: nodeId,
|
||||
data: {
|
||||
...item.data,
|
||||
name: computedNewNodeName({
|
||||
templateName: item.data?.name || '',
|
||||
flowNodeType: item.data?.flowNodeType || '',
|
||||
pluginId: item.data?.pluginId
|
||||
}),
|
||||
nodeId,
|
||||
parentNodeId: undefined
|
||||
},
|
||||
// Position nodes relative to the mouse position
|
||||
position: {
|
||||
x: pasteFlowPosition.x + (item.position.x - originalCenterX),
|
||||
y: pasteFlowPosition.y + (item.position.y - originalCenterY)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Reset all node to not select and concat new node
|
||||
setNodes((prev) =>
|
||||
@@ -104,7 +124,15 @@ export const useKeyboard = () => {
|
||||
.concat(newNodes)
|
||||
);
|
||||
} catch (error) {}
|
||||
}, [computedNewNodeName, hasInputtingElement, myModels, setNodes]);
|
||||
}, [
|
||||
computedNewNodeName,
|
||||
hasInputtingElement,
|
||||
mouseInCanvas,
|
||||
mousePosition,
|
||||
myModels,
|
||||
screenToFlowPosition,
|
||||
setNodes
|
||||
]);
|
||||
|
||||
useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => {
|
||||
if (!mouseInCanvas) return;
|
||||
|
||||
@@ -21,6 +21,9 @@ type WorkflowUIContextValue = {
|
||||
/** 鼠标是否在 Canvas 中 */
|
||||
mouseInCanvas: boolean;
|
||||
|
||||
/** 鼠标在 Canvas 中的屏幕位置 */
|
||||
mousePosition: { x: number; y: number } | null;
|
||||
|
||||
/** ReactFlow 包装器 callback ref */
|
||||
reactFlowWrapperCallback: (node: HTMLDivElement | null) => void;
|
||||
|
||||
@@ -50,6 +53,7 @@ export const WorkflowUIContext = createContext<WorkflowUIContextValue>({
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
mouseInCanvas: false,
|
||||
mousePosition: null,
|
||||
reactFlowWrapperCallback: function (_node: HTMLDivElement | null): void {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
@@ -74,6 +78,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
|
||||
// Canvas 交互
|
||||
const [mouseInCanvas, setMouseInCanvas] = useState(false);
|
||||
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
|
||||
// 使用 ref 来存储 wrapper 引用和 cleanup 函数
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
@@ -93,16 +98,23 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
};
|
||||
const handleMouseOutCanvas = () => {
|
||||
setMouseInCanvas(false);
|
||||
setMousePosition(null);
|
||||
};
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMousePosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
node.addEventListener('mouseenter', handleMouseInCanvas);
|
||||
node.addEventListener('mouseleave', handleMouseOutCanvas);
|
||||
node.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
// 存储 cleanup 函数到 ref
|
||||
cleanupRef.current = () => {
|
||||
node.removeEventListener('mouseenter', handleMouseInCanvas);
|
||||
node.removeEventListener('mouseleave', handleMouseOutCanvas);
|
||||
node.removeEventListener('mousemove', handleMouseMove);
|
||||
setMouseInCanvas(false);
|
||||
setMousePosition(null);
|
||||
};
|
||||
} else {
|
||||
(reactFlowWrapper as any).current = null;
|
||||
@@ -139,6 +151,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
hoverEdgeId,
|
||||
setHoverEdgeId,
|
||||
mouseInCanvas,
|
||||
mousePosition,
|
||||
reactFlowWrapperCallback,
|
||||
workflowControlMode,
|
||||
setWorkflowControlMode,
|
||||
@@ -151,6 +164,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
|
||||
hoverNodeId,
|
||||
hoverEdgeId,
|
||||
mouseInCanvas,
|
||||
mousePosition,
|
||||
reactFlowWrapperCallback,
|
||||
workflowControlMode,
|
||||
setWorkflowControlMode,
|
||||
|
||||
Reference in New Issue
Block a user