Commit fea1c4ed by Archer Committed by GitHub

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

* perf: workflow node past

* feat: table export

* feat: table export
parent 6ec16cf0
...@@ -6,10 +6,12 @@ description: 'FastGPT V4.14.6 更新说明' ...@@ -6,10 +6,12 @@ description: 'FastGPT V4.14.6 更新说明'
## 🚀 新增内容 ## 🚀 新增内容
1. Markdown 表格支持导出 csv。
## ⚙️ 优化 ## ⚙️ 优化
1. 工作流触摸板移动时,遇到输入框后会被强制阻拦。 1. 工作流触摸板移动时,遇到输入框后会被强制阻拦。
2. 工作流粘贴节点,精确按鼠标位置粘贴。
## 🐛 修复 ## 🐛 修复
......
...@@ -104,7 +104,7 @@ ...@@ -104,7 +104,7 @@
"document/content/docs/protocol/terms.en.mdx": "2025-12-15T23:36:54+08:00", "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/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.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/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-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", "document/content/docs/upgrading/4-11/4110.mdx": "2025-08-05T23:20:39+08:00",
...@@ -122,7 +122,8 @@ ...@@ -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/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/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/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/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/41.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00",
......
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'; ...@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown';
import RemarkGfm from 'remark-gfm'; import RemarkGfm from 'remark-gfm';
import RehypeExternalLinks from 'rehype-external-links'; import RehypeExternalLinks from 'rehype-external-links';
import { Box, Code as ChakraCode, Image, Link } from '@chakra-ui/react'; import { Box, Code as ChakraCode, Image, Link } from '@chakra-ui/react';
import MarkdownTable from './MarkdownTable';
type MarkdownProps = { type MarkdownProps = {
source: string; source: string;
...@@ -114,13 +115,7 @@ const Markdown = ({ source, className }: MarkdownProps) => { ...@@ -114,13 +115,7 @@ const Markdown = ({ source, className }: MarkdownProps) => {
// 水平线 // 水平线
hr: () => <Box as="hr" my={4} borderColor="myGray.200" />, hr: () => <Box as="hr" my={4} borderColor="myGray.200" />,
// 表格 // 表格
table: ({ children }: any) => ( table: MarkdownTable as any,
<Box overflowX="auto" my={2}>
<Box as="table" w="100%" border="1px solid" borderColor="myGray.200" borderRadius="md">
{children}
</Box>
</Box>
),
thead: ({ children }: any) => ( thead: ({ children }: any) => (
<Box as="thead" bg="myGray.50"> <Box as="thead" bg="myGray.50">
{children} {children}
......
/**
* 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 @@ ...@@ -219,6 +219,7 @@
margin-bottom: 0; margin-bottom: 0;
} }
.markdown table { .markdown table {
position: relative;
width: 100%; width: 100%;
} }
.markdown table th { .markdown table th {
......
...@@ -14,6 +14,7 @@ import { Box } from '@chakra-ui/react'; ...@@ -14,6 +14,7 @@ import { Box } from '@chakra-ui/react';
import { CodeClassNameEnum, mdTextFormat } from './utils'; import { CodeClassNameEnum, mdTextFormat } from './utils';
import { useCreation } from 'ahooks'; import { useCreation } from 'ahooks';
import type { AProps } from './A'; import type { AProps } from './A';
import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false }); const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false }); const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
...@@ -57,6 +58,7 @@ const MarkdownRender = ({ ...@@ -57,6 +58,7 @@ const MarkdownRender = ({
img: (props: any) => <Image {...props} alt={props.alt} chatAuthData={chatAuthData} />, img: (props: any) => <Image {...props} alt={props.alt} chatAuthData={chatAuthData} />,
pre: RewritePre, pre: RewritePre,
code: Code, code: Code,
table: MarkdownTable as any,
a: (props: any) => ( a: (props: any) => (
<A <A
{...props} {...props}
......
...@@ -2,7 +2,7 @@ import { useCallback } from 'react'; ...@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getNanoid } from '@fastgpt/global/common/string/tools';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData'; import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { useTranslation } from 'next-i18next'; 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 { type FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { useWorkflowUtils } from './useUtils'; import { useWorkflowUtils } from './useUtils';
...@@ -18,6 +18,7 @@ export const useKeyboard = () => { ...@@ -18,6 +18,7 @@ export const useKeyboard = () => {
const getNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodes); const getNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.getNodes);
const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes); const setNodes = useContextSelector(WorkflowBufferDataContext, (v) => v.setNodes);
const mouseInCanvas = useContextSelector(WorkflowUIContext, (v) => v.mouseInCanvas); const mouseInCanvas = useContextSelector(WorkflowUIContext, (v) => v.mouseInCanvas);
const mousePosition = useContextSelector(WorkflowUIContext, (v) => v.mousePosition);
const { getMyModelList } = useSystemStore(); const { getMyModelList } = useSystemStore();
const { data: myModels } = useRequest2(getMyModelList, { const { data: myModels } = useRequest2(getMyModelList, {
...@@ -26,6 +27,7 @@ export const useKeyboard = () => { ...@@ -26,6 +27,7 @@ export const useKeyboard = () => {
const { copyData } = useCopyData(); const { copyData } = useCopyData();
const { computedNewNodeName } = useWorkflowUtils(); const { computedNewNodeName } = useWorkflowUtils();
const { screenToFlowPosition } = useReactFlow();
const isDowningCtrl = useKeyPress(['Meta', 'Control']); const isDowningCtrl = useKeyPress(['Meta', 'Control']);
...@@ -55,17 +57,34 @@ export const useKeyboard = () => { ...@@ -55,17 +57,34 @@ export const useKeyboard = () => {
const onPaste = useCallback(async () => { const onPaste = useCallback(async () => {
if (hasInputtingElement()) return; 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(); const copyResult = await navigator.clipboard.readText();
try { try {
const parseData = JSON.parse(copyResult) as Node<FlowNodeItemType, string | undefined>[]; const parseData = JSON.parse(copyResult) as Node<FlowNodeItemType, string | undefined>[];
// check is array // check is array
if (!Array.isArray(parseData)) return; if (!Array.isArray(parseData)) return;
// filter workflow data // filter workflow data
const newNodes = parseData const filteredData = parseData.filter(
.filter(
(item) => !!item.type && item.data?.unique !== true && item.type !== FlowNodeTypeEnum.loop (item) => !!item.type && item.data?.unique !== true && item.type !== FlowNodeTypeEnum.loop
) );
.map((item) => {
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(); const nodeId = getNanoid();
item.data.inputs.forEach((input) => { item.data.inputs.forEach((input) => {
if (input.key === 'model') { if (input.key === 'model') {
...@@ -86,9 +105,10 @@ export const useKeyboard = () => { ...@@ -86,9 +105,10 @@ export const useKeyboard = () => {
nodeId, nodeId,
parentNodeId: undefined parentNodeId: undefined
}, },
// Position nodes relative to the mouse position
position: { position: {
x: item.position.x + 100, x: pasteFlowPosition.x + (item.position.x - originalCenterX),
y: item.position.y + 100 y: pasteFlowPosition.y + (item.position.y - originalCenterY)
} }
}; };
}); });
...@@ -104,7 +124,15 @@ export const useKeyboard = () => { ...@@ -104,7 +124,15 @@ export const useKeyboard = () => {
.concat(newNodes) .concat(newNodes)
); );
} catch (error) {} } catch (error) {}
}, [computedNewNodeName, hasInputtingElement, myModels, setNodes]); }, [
computedNewNodeName,
hasInputtingElement,
mouseInCanvas,
mousePosition,
myModels,
screenToFlowPosition,
setNodes
]);
useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => { useKeyPressEffect(['ctrl.c', 'meta.c'], (e) => {
if (!mouseInCanvas) return; if (!mouseInCanvas) return;
......
...@@ -21,6 +21,9 @@ type WorkflowUIContextValue = { ...@@ -21,6 +21,9 @@ type WorkflowUIContextValue = {
/** 鼠标是否在 Canvas 中 */ /** 鼠标是否在 Canvas 中 */
mouseInCanvas: boolean; mouseInCanvas: boolean;
/** 鼠标在 Canvas 中的屏幕位置 */
mousePosition: { x: number; y: number } | null;
/** ReactFlow 包装器 callback ref */ /** ReactFlow 包装器 callback ref */
reactFlowWrapperCallback: (node: HTMLDivElement | null) => void; reactFlowWrapperCallback: (node: HTMLDivElement | null) => void;
...@@ -50,6 +53,7 @@ export const WorkflowUIContext = createContext<WorkflowUIContextValue>({ ...@@ -50,6 +53,7 @@ export const WorkflowUIContext = createContext<WorkflowUIContextValue>({
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
mouseInCanvas: false, mouseInCanvas: false,
mousePosition: null,
reactFlowWrapperCallback: function (_node: HTMLDivElement | null): void { reactFlowWrapperCallback: function (_node: HTMLDivElement | null): void {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
}, },
...@@ -74,6 +78,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) => ...@@ -74,6 +78,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
// Canvas 交互 // Canvas 交互
const [mouseInCanvas, setMouseInCanvas] = useState(false); const [mouseInCanvas, setMouseInCanvas] = useState(false);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
// 使用 ref 来存储 wrapper 引用和 cleanup 函数 // 使用 ref 来存储 wrapper 引用和 cleanup 函数
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null); const cleanupRef = useRef<(() => void) | null>(null);
...@@ -93,16 +98,23 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) => ...@@ -93,16 +98,23 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
}; };
const handleMouseOutCanvas = () => { const handleMouseOutCanvas = () => {
setMouseInCanvas(false); setMouseInCanvas(false);
setMousePosition(null);
};
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
}; };
node.addEventListener('mouseenter', handleMouseInCanvas); node.addEventListener('mouseenter', handleMouseInCanvas);
node.addEventListener('mouseleave', handleMouseOutCanvas); node.addEventListener('mouseleave', handleMouseOutCanvas);
node.addEventListener('mousemove', handleMouseMove);
// 存储 cleanup 函数到 ref // 存储 cleanup 函数到 ref
cleanupRef.current = () => { cleanupRef.current = () => {
node.removeEventListener('mouseenter', handleMouseInCanvas); node.removeEventListener('mouseenter', handleMouseInCanvas);
node.removeEventListener('mouseleave', handleMouseOutCanvas); node.removeEventListener('mouseleave', handleMouseOutCanvas);
node.removeEventListener('mousemove', handleMouseMove);
setMouseInCanvas(false); setMouseInCanvas(false);
setMousePosition(null);
}; };
} else { } else {
(reactFlowWrapper as any).current = null; (reactFlowWrapper as any).current = null;
...@@ -139,6 +151,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) => ...@@ -139,6 +151,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
hoverEdgeId, hoverEdgeId,
setHoverEdgeId, setHoverEdgeId,
mouseInCanvas, mouseInCanvas,
mousePosition,
reactFlowWrapperCallback, reactFlowWrapperCallback,
workflowControlMode, workflowControlMode,
setWorkflowControlMode, setWorkflowControlMode,
...@@ -151,6 +164,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) => ...@@ -151,6 +164,7 @@ export const WorkflowUIProvider: React.FC<PropsWithChildren> = ({ children }) =>
hoverNodeId, hoverNodeId,
hoverEdgeId, hoverEdgeId,
mouseInCanvas, mouseInCanvas,
mousePosition,
reactFlowWrapperCallback, reactFlowWrapperCallback,
workflowControlMode, workflowControlMode,
setWorkflowControlMode, setWorkflowControlMode,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment