refactor(table): 表格复制按钮复用 CopyButton,下载改为 markdown+BOM

- MarkdownTable 导出为公共组件
- 复制按钮直接复用 CopyButton,行为与 iframe 复制一致
- 表格数据通过 tableRef 在 render 阶段同步计算
- useLayoutEffect 确保首次渲染后即可获取正确数据
- 下载按钮改为 markdown 格式 (.md),UTF-8 with BOM
- 移除废弃的 escapeCsvCell / toCsvTable
This commit is contained in:
mt 2026-06-11 09:50:19 +08:00
parent 1637a0e71c
commit 407618baf0

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"; import { DownloadIcon } from "lucide-react";
import { useCallback, useMemo, useState, type MouseEvent } from "react"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { import type {
AnchorHTMLAttributes, AnchorHTMLAttributes,
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
@ -14,7 +14,9 @@ import {
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { streamdownPlugins } from "@/core/streamdown"; 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"; import { CitationLink } from "../citations/citation-link";
@ -56,21 +58,9 @@ function toMarkdownTable(data: TableData): string {
return [headerLine, dividerLine, ...rowLines].join("\n"); return [headerLine, dividerLine, ...rowLines].join("\n");
} }
function escapeCsvCell(value: string): string { function downloadMarkdownFile(content: string, filename: 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) {
const blob = new Blob(["\uFEFF", content], { const blob = new Blob(["\uFEFF", content], {
type: "text/csv;charset=utf-8", type: "text/markdown;charset=utf-8",
}); });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
@ -80,58 +70,43 @@ function downloadCsvFile(content: string, filename: string) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function MarkdownTable({ export function MarkdownTable({
className, className,
children, children,
copyLabel, copyLabel: _copyLabel,
downloadLabel, downloadLabel,
...props ...props
}: ComponentPropsWithoutRef<"table"> & { }: ComponentPropsWithoutRef<"table"> & {
copyLabel: string; copyLabel: string;
downloadLabel: string; downloadLabel: string;
}) { }) {
const [copied, setCopied] = useState(false); const tableRef = useRef<HTMLTableElement>(null);
const [, forceUpdate] = useState(0);
const getTableData = useCallback((event: MouseEvent<HTMLButtonElement>) => { // 首次 mount 后 tableRef 才被赋值,用 useLayoutEffect 在 paint 前强制刷新
const wrapper = event.currentTarget.closest( useLayoutEffect(() => {
'[data-streamdown="table-wrapper"]', forceUpdate((n) => n + 1);
);
const table = wrapper?.querySelector("table");
if (!(table instanceof HTMLTableElement)) return null;
return parseTableData(table);
}, []); }, []);
const handleCopy = useCallback( // 在 render 阶段直接从 DOM ref 计算,不依赖 effect 异步更新
async (event: MouseEvent<HTMLButtonElement>) => { // tableRef 在上一次渲染的 commit 阶段已设置,本次渲染可用
const data = getTableData(event); const clipboardData = (() => {
if (!data) return; const table = tableRef.current;
if (!table) return "";
const data = parseTableData(table);
if (!data) return "";
return toMarkdownTable(data);
})();
const markdown = toMarkdownTable(data); const handleDownload = useCallback(() => {
if (!markdown) return; const table = tableRef.current;
if (!table) return;
try { const data = parseTableData(table);
await copyToClipboard(markdown); if (!data) return;
setCopied(true); const markdown = toMarkdownTable(data);
window.setTimeout(() => setCopied(false), 2000); if (!markdown) return;
} catch { downloadMarkdownFile(markdown, "table.md");
// no-op }, []);
}
},
[getTableData],
);
const handleDownload = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
const data = getTableData(event);
if (!data) return;
const csv = toCsvTable(data);
if (!csv) return;
downloadCsvFile(csv, "table.csv");
},
[getTableData],
);
return ( return (
<div <div
@ -139,25 +114,20 @@ function MarkdownTable({
data-streamdown="table-wrapper" data-streamdown="table-wrapper"
> >
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<button <CopyButton className="text-muted-foreground hover:bg-transparent hover:text-foreground cursor-pointer p-1 transition-all" clipboardData={clipboardData} />
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all" <Tooltip content={downloadLabel}>
onClick={handleCopy} <button
title={copyLabel} className="h-[32px] w-[32px] text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all"
type="button" onClick={handleDownload}
> type="button"
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />} >
</button> <DownloadIcon size={16} />
<button </button>
className="text-muted-foreground hover:text-foreground cursor-pointer p-1 transition-all" </Tooltip>
onClick={handleDownload}
title={downloadLabel}
type="button"
>
<DownloadIcon size={14} />
</button>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table <table
ref={tableRef}
className={cn( className={cn(
"border-border w-full border-collapse border", "border-border w-full border-collapse border",
className, className,