From 407618baf0841b6dd5fa49d290a03746df194a9e Mon Sep 17 00:00:00 2001 From: mt Date: Thu, 11 Jun 2026 09:50:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor(table):=20=E8=A1=A8=E6=A0=BC=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E6=8C=89=E9=92=AE=E5=A4=8D=E7=94=A8=20CopyButton?= =?UTF-8?q?=EF=BC=8C=E4=B8=8B=E8=BD=BD=E6=94=B9=E4=B8=BA=20markdown+BOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MarkdownTable 导出为公共组件 - 复制按钮直接复用 CopyButton,行为与 iframe 复制一致 - 表格数据通过 tableRef 在 render 阶段同步计算 - useLayoutEffect 确保首次渲染后即可获取正确数据 - 下载按钮改为 markdown 格式 (.md),UTF-8 with BOM - 移除废弃的 escapeCsvCell / toCsvTable --- .../workspace/messages/markdown-content.tsx | 116 +++++++----------- 1 file changed, 43 insertions(+), 73 deletions(-) diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 5720b7de..2c7026b0 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,7 +1,7 @@ "use client"; -import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"; -import { useCallback, useMemo, useState, type MouseEvent } from "react"; +import { DownloadIcon } from "lucide-react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, ComponentPropsWithoutRef, @@ -14,7 +14,9 @@ import { } from "@/components/ai-elements/message"; import { useI18n } from "@/core/i18n/hooks"; import { streamdownPlugins } from "@/core/streamdown"; -import { cn, copyToClipboard } from "@/lib/utils"; +import { CopyButton } from "@/components/workspace/copy-button"; +import { Tooltip } from "@/components/workspace/tooltip"; +import { cn } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; @@ -56,21 +58,9 @@ function toMarkdownTable(data: TableData): string { return [headerLine, dividerLine, ...rowLines].join("\n"); } -function escapeCsvCell(value: string): string { - if (!/[",\n\r]/.test(value)) return value; - return `"${value.replaceAll('"', '""')}"`; -} - -function toCsvTable(data: TableData): string { - if (data.headers.length === 0) return ""; - return [data.headers, ...data.rows] - .map((row) => row.map(escapeCsvCell).join(",")) - .join("\n"); -} - -function downloadCsvFile(content: string, filename: string) { +function downloadMarkdownFile(content: string, filename: string) { const blob = new Blob(["\uFEFF", content], { - type: "text/csv;charset=utf-8", + type: "text/markdown;charset=utf-8", }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); @@ -80,58 +70,43 @@ function downloadCsvFile(content: string, filename: string) { URL.revokeObjectURL(url); } -function MarkdownTable({ +export function MarkdownTable({ className, children, - copyLabel, + copyLabel: _copyLabel, downloadLabel, ...props }: ComponentPropsWithoutRef<"table"> & { copyLabel: string; downloadLabel: string; }) { - const [copied, setCopied] = useState(false); + const tableRef = useRef(null); + const [, forceUpdate] = useState(0); - const getTableData = useCallback((event: MouseEvent) => { - const wrapper = event.currentTarget.closest( - '[data-streamdown="table-wrapper"]', - ); - const table = wrapper?.querySelector("table"); - if (!(table instanceof HTMLTableElement)) return null; - return parseTableData(table); + // 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新 + useLayoutEffect(() => { + forceUpdate((n) => n + 1); }, []); - const handleCopy = useCallback( - async (event: MouseEvent) => { - const data = getTableData(event); - if (!data) return; + // 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新 + // tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用 + const clipboardData = (() => { + const table = tableRef.current; + if (!table) return ""; + const data = parseTableData(table); + if (!data) return ""; + return toMarkdownTable(data); + })(); - const markdown = toMarkdownTable(data); - if (!markdown) return; - - try { - await copyToClipboard(markdown); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - } catch { - // no-op - } - }, - [getTableData], - ); - - const handleDownload = useCallback( - (event: MouseEvent) => { - const data = getTableData(event); - if (!data) return; - - const csv = toCsvTable(data); - if (!csv) return; - - downloadCsvFile(csv, "table.csv"); - }, - [getTableData], - ); + const handleDownload = useCallback(() => { + const table = tableRef.current; + if (!table) return; + const data = parseTableData(table); + if (!data) return; + const markdown = toMarkdownTable(data); + if (!markdown) return; + downloadMarkdownFile(markdown, "table.md"); + }, []); return (
- - + + + +