perf: workflow node past;feat: table export (#6250)

* perf: workflow node past

* feat: table export

* feat: table export
This commit is contained in:
Archer
2026-01-12 21:07:44 +08:00
committed by GitHub
parent 6ec16cf0cf
commit fea1c4ed14
9 changed files with 212 additions and 41 deletions

View File

@@ -6,10 +6,12 @@ description: 'FastGPT V4.14.6 更新说明'
## 🚀 新增内容
1. Markdown 表格支持导出 csv。
## ⚙️ 优化
1. 工作流触摸板移动时,遇到输入框后会被强制阻拦。
2. 工作流粘贴节点,精确按鼠标位置粘贴。
## 🐛 修复

View File

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

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

View File

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

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

View File

@@ -219,6 +219,7 @@
margin-bottom: 0;
}
.markdown table {
position: relative;
width: 100%;
}
.markdown table th {

View File

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

View File

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

View File

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