From fea1c4ed14ca792e78a85d707fd45091a35405b7 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Mon, 12 Jan 2026 21:07:44 +0800 Subject: [PATCH] perf: workflow node past;feat: table export (#6250) * perf: workflow node past * feat: table export * feat: table export --- document/content/docs/upgrading/4-14/4146.mdx | 2 + document/data/doc-last-modified.json | 5 +- .../common/Markdown/MarkdownTable.tsx | 53 +++++++++++ .../web/components/common/Markdown/index.tsx | 9 +- .../web/components/common/Markdown/utils.ts | 75 +++++++++++++++ .../src/components/Markdown/index.module.scss | 1 + .../app/src/components/Markdown/index.tsx | 2 + .../Flow/hooks/useKeyboard.tsx | 92 ++++++++++++------- .../context/workflowUIContext.tsx | 14 +++ 9 files changed, 212 insertions(+), 41 deletions(-) create mode 100644 packages/web/components/common/Markdown/MarkdownTable.tsx create mode 100644 packages/web/components/common/Markdown/utils.ts diff --git a/document/content/docs/upgrading/4-14/4146.mdx b/document/content/docs/upgrading/4-14/4146.mdx index c3f024996c..db9f62519d 100644 --- a/document/content/docs/upgrading/4-14/4146.mdx +++ b/document/content/docs/upgrading/4-14/4146.mdx @@ -6,10 +6,12 @@ description: 'FastGPT V4.14.6 更新说明' ## 🚀 新增内容 +1. Markdown 表格支持导出 csv。 ## ⚙️ 优化 1. 工作流触摸板移动时,遇到输入框后会被强制阻拦。 +2. 工作流粘贴节点,精确按鼠标位置粘贴。 ## 🐛 修复 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 93f60dfe91..2d9c16c087 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -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", diff --git a/packages/web/components/common/Markdown/MarkdownTable.tsx b/packages/web/components/common/Markdown/MarkdownTable.tsx new file mode 100644 index 0000000000..cd0d2c1490 --- /dev/null +++ b/packages/web/components/common/Markdown/MarkdownTable.tsx @@ -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(null); + + const handleExport = useCallback(() => { + if (tableRef.current) { + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + exportTableToCSV(tableRef.current, `table-${timestamp}`); + } + }, []); + + return ( + + } + size={'xs'} + display="none" + variant={'whiteBase'} + onClick={handleExport} + position="absolute" + top={1} + right={2} + zIndex={1} + aria-label={''} + /> + {children} + + ); +}; + +export default React.memo(MarkdownTable); diff --git a/packages/web/components/common/Markdown/index.tsx b/packages/web/components/common/Markdown/index.tsx index e5bfb8bfe1..ed79236462 100644 --- a/packages/web/components/common/Markdown/index.tsx +++ b/packages/web/components/common/Markdown/index.tsx @@ -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: () => , // 表格 - table: ({ children }: any) => ( - - - {children} - - - ), + table: MarkdownTable as any, thead: ({ children }: any) => ( {children} diff --git a/packages/web/components/common/Markdown/utils.ts b/packages/web/components/common/Markdown/utils.ts new file mode 100644 index 0000000000..a22e40639c --- /dev/null +++ b/packages/web/components/common/Markdown/utils.ts @@ -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; +}; diff --git a/projects/app/src/components/Markdown/index.module.scss b/projects/app/src/components/Markdown/index.module.scss index 52332b6c16..6026a43afd 100644 --- a/projects/app/src/components/Markdown/index.module.scss +++ b/projects/app/src/components/Markdown/index.module.scss @@ -219,6 +219,7 @@ margin-bottom: 0; } .markdown table { + position: relative; width: 100%; } .markdown table th { diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx index 6d50b1922d..53f01a1463 100644 --- a/projects/app/src/components/Markdown/index.tsx +++ b/projects/app/src/components/Markdown/index.tsx @@ -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) => {props.alt}, pre: RewritePre, code: Code, + table: MarkdownTable as any, a: (props: any) => ( { 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[]; // 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; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUIContext.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUIContext.tsx index ecb91a63ad..8df71bdc63 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUIContext.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/context/workflowUIContext.tsx @@ -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({ 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 = ({ children }) => // Canvas 交互 const [mouseInCanvas, setMouseInCanvas] = useState(false); + const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); // 使用 ref 来存储 wrapper 引用和 cleanup 函数 const reactFlowWrapper = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); @@ -93,16 +98,23 @@ export const WorkflowUIProvider: React.FC = ({ 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 = ({ children }) => hoverEdgeId, setHoverEdgeId, mouseInCanvas, + mousePosition, reactFlowWrapperCallback, workflowControlMode, setWorkflowControlMode, @@ -151,6 +164,7 @@ export const WorkflowUIProvider: React.FC = ({ children }) => hoverNodeId, hoverEdgeId, mouseInCanvas, + mousePosition, reactFlowWrapperCallback, workflowControlMode, setWorkflowControlMode,